From 5aaa73ec24808c2a2dbc0abdcc6efb5d1f4c8ee0 Mon Sep 17 00:00:00 2001 From: magdev Date: Tue, 3 Feb 2026 11:16:18 +0100 Subject: [PATCH] feat: Add dashboard extension hook for third-party plugins (v0.4.6) Add wp_prometheus_register_dashboards action hook allowing third-party plugins to register their own Grafana dashboard templates. - DashboardProvider: registration system with file/JSON support - Security: path traversal protection, JSON validation - Admin UI: "Extension" badge and plugin attribution - Isolated mode support for dashboard registration hook Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 17 ++ CLAUDE.md | 29 ++- README.md | 51 +++++ assets/css/admin.css | 26 +++ languages/wp-prometheus-de_CH.mo | Bin 20987 -> 22340 bytes languages/wp-prometheus-de_CH.po | 46 ++++ languages/wp-prometheus.pot | 46 ++++ src/Admin/DashboardProvider.php | 368 ++++++++++++++++++++++++++++--- src/Admin/Settings.php | 51 +++-- wp-prometheus.php | 4 +- 10 files changed, 590 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a801d59..3be76a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.6] - 2026-02-03 + +### Added + +- Dashboard extension hook `wp_prometheus_register_dashboards` for third-party plugins +- Third-party plugins can now register their own Grafana dashboard templates +- Support for file-based and inline JSON dashboard registration +- "Extension" badge for third-party dashboards in admin UI +- Plugin attribution display for third-party dashboards +- Security: Path traversal protection for registered dashboard files +- Isolated mode support for dashboard registration hook + +### Changed + +- DashboardProvider now supports both built-in and third-party registered dashboards +- Dashboard cards show source (built-in vs extension) with visual distinction + ## [0.4.5] - 2026-02-02 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 68d8252..7990fee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ This plugin provides a Prometheus `/metrics` endpoint and an extensible way to a - Grafana dashboard templates for easy visualization - Dedicated plugin settings under 'Settings/Metrics' menu - Extensible by other plugins using `wp_prometheus_collect_metrics` action hook +- Dashboard extension hook `wp_prometheus_register_dashboards` for third-party Grafana dashboards - License management integration ### Key Fact: 100% AI-Generated @@ -33,7 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w **Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. -*No planned features at this time.* +*No pending roadmap items.* ## Technical Stack @@ -291,6 +292,32 @@ add_action( 'wp_prometheus_collect_metrics', function( $collector ) { ## Session History +### 2026-02-03 - Dashboard Extension Hook (v0.4.6) + +- Added `wp_prometheus_register_dashboards` action hook for third-party plugins +- Third-party plugins can now register their own Grafana dashboard templates +- Implementation in `DashboardProvider.php`: + - `register_dashboard(slug, args)` method for registrations + - Supports file-based dashboards (absolute path to JSON) or inline JSON content + - Security: Path traversal protection (files must be under `WP_CONTENT_DIR`) + - `fire_registration_hook()` with output buffering and exception handling + - Respects isolated mode setting (skips third-party hooks when enabled) + - `is_third_party()` and `get_plugin_name()` helper methods +- Updated admin UI in Settings.php: + - "Extension" badge displayed on third-party dashboard cards + - Plugin attribution shown below third-party dashboards + - Visual distinction with blue border for third-party cards +- **Key Learning**: Extension hook design pattern + - Fire hook lazily on first `get_available()` call, not in constructor + - Use `$hook_fired` flag to prevent double-firing + - Wrap hook execution in try-catch to isolate failures + - Validate registrations thoroughly before accepting them +- **Key Learning**: Security for file-based registrations + - Require absolute paths (`path_is_absolute()`) + - Validate files exist and are readable + - Use `realpath()` to resolve symlinks and prevent traversal + - Restrict to `WP_CONTENT_DIR` (not just plugin directories) + ### 2026-02-02 - Settings Persistence Fix (v0.4.5) - Fixed critical bug where settings would get cleared when saving from different Metrics sub-tabs diff --git a/README.md b/README.md index b2a3b62..64f7768 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,13 @@ A WordPress plugin that provides a Prometheus-compatible `/metrics` endpoint wit - Prometheus-compatible authenticated `/metrics` endpoint - Default WordPress metrics (users, posts, comments, plugins) +- Runtime metrics (HTTP requests, database queries) +- Cron job and transient cache metrics +- WooCommerce integration (products, orders, revenue) +- Custom metric builder with admin UI +- Grafana dashboard templates with download - Extensible by other plugins using hooks +- Dashboard extension hook for third-party Grafana dashboards - Settings page under Settings > Metrics - Bearer token authentication - License management integration @@ -154,6 +160,51 @@ $histogram = $collector->register_histogram( $name, $help, $labels, $buckets ); $histogram->observe( $value, $labelValues ); ``` +## Extending with Custom Dashboards (v0.4.6+) + +Add your own Grafana dashboard templates using the `wp_prometheus_register_dashboards` action: + +```php +add_action( 'wp_prometheus_register_dashboards', function( $provider ) { + // File-based dashboard + $provider->register_dashboard( 'my-plugin-dashboard', array( + 'title' => __( 'My Plugin Metrics', 'my-plugin' ), + 'description' => __( 'Dashboard for my custom metrics', 'my-plugin' ), + 'icon' => 'dashicons-chart-bar', + 'file' => MY_PLUGIN_PATH . 'assets/dashboards/my-dashboard.json', + 'plugin' => 'My Plugin Name', + ) ); + + // OR inline JSON dashboard + $provider->register_dashboard( 'dynamic-dashboard', array( + 'title' => __( 'Dynamic Dashboard', 'my-plugin' ), + 'description' => __( 'Dynamically generated dashboard', 'my-plugin' ), + 'icon' => 'dashicons-admin-generic', + 'json' => json_encode( $dashboard_array ), + 'plugin' => 'My Plugin Name', + ) ); +} ); +``` + +### Registration Parameters + +| Parameter | Required | Description | +| --------- | -------- | ----------- | +| `title` | Yes | Dashboard title displayed in admin | +| `description` | No | Description shown below the title | +| `icon` | No | Dashicon class (default: `dashicons-chart-line`) | +| `file` | Yes* | Absolute path to JSON file | +| `json` | Yes* | Inline JSON content | +| `plugin` | No | Plugin name for attribution | + +*Either `file` or `json` is required, but not both. + +### Security Notes + +- File paths must be absolute and within `wp-content/` +- Inline JSON is validated during registration +- Third-party dashboards are marked with an "Extension" badge in the admin UI + ## Development ### Build for Release diff --git a/assets/css/admin.css b/assets/css/admin.css index cb552e5..96397a7 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -216,6 +216,32 @@ font-size: 13px; } +/* Third-party dashboard card styling */ +.wp-prometheus-dashboard-card.third-party { + position: relative; + border-color: #2271b1; +} + +.wp-prometheus-dashboard-card .dashboard-badge { + position: absolute; + top: -8px; + right: -8px; + background: #2271b1; + color: #fff; + font-size: 10px; + font-weight: 600; + padding: 3px 8px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wp-prometheus-dashboard-card .dashboard-plugin { + color: #646970; + margin: -5px 0 15px 0; + font-style: italic; +} + /* Import options panel */ #import-options { border-radius: 4px; diff --git a/languages/wp-prometheus-de_CH.mo b/languages/wp-prometheus-de_CH.mo index 415103a6257613cd8b3aec47571a0e13b798693c..8b7480b775ce58f7b37f922ad3f15d166f6b5a2d 100644 GIT binary patch delta 6077 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 U9PaT$7CUM#7KXXgih_Or2WGynhq diff --git a/languages/wp-prometheus-de_CH.po b/languages/wp-prometheus-de_CH.po index c6cc7de..b34e09b 100644 --- a/languages/wp-prometheus-de_CH.po +++ b/languages/wp-prometheus-de_CH.po @@ -947,3 +947,49 @@ msgstr "Laufzeit-Metriken erfassen HTTP-Anfragen und Datenbank-Abfragen ueber me #: src/Admin/Settings.php msgid "Reset Data" msgstr "Daten zuruecksetzen" + +#: src/Admin/Settings.php +msgid "Extension" +msgstr "Erweiterung" + +#. translators: %s: Plugin name +#: src/Admin/Settings.php +msgid "Provided by: %s" +msgstr "Bereitgestellt von: %s" + +#: src/Admin/Settings.php +msgid "No dashboards available." +msgstr "Keine Dashboards verfuegbar." + +#: src/Admin/Settings.php +msgid "Pre-built dashboards for visualizing your WordPress metrics in Grafana." +msgstr "Vorgefertigte Dashboards zur Visualisierung Ihrer WordPress-Metriken in Grafana." + +#: src/Admin/Settings.php +msgid "Installation Instructions" +msgstr "Installationsanleitung" + +#: src/Admin/Settings.php +msgid "Download the JSON file for your desired dashboard." +msgstr "Laden Sie die JSON-Datei fuer das gewuenschte Dashboard herunter." + +#: src/Admin/Settings.php +msgid "In Grafana, go to Dashboards → Import." +msgstr "Gehen Sie in Grafana zu Dashboards → Import." + +#: src/Admin/Settings.php +msgid "Upload the JSON file or paste its contents." +msgstr "Laden Sie die JSON-Datei hoch oder fuegen Sie den Inhalt ein." + +#: src/Admin/Settings.php +msgid "Select your Prometheus data source when prompted." +msgstr "Waehlen Sie Ihre Prometheus-Datenquelle, wenn Sie dazu aufgefordert werden." + +#: src/Admin/Settings.php +msgid "Click Import to create the dashboard." +msgstr "Klicken Sie auf Import, um das Dashboard zu erstellen." + +#. translators: %s: Metrics URL +#: src/Admin/Settings.php +msgid "Make sure your Prometheus instance is configured to scrape %s with the correct authentication token." +msgstr "Stellen Sie sicher, dass Ihre Prometheus-Instanz so konfiguriert ist, dass sie %s mit dem richtigen Authentifizierungs-Token abruft." diff --git a/languages/wp-prometheus.pot b/languages/wp-prometheus.pot index fd5b117..50dece5 100644 --- a/languages/wp-prometheus.pot +++ b/languages/wp-prometheus.pot @@ -944,3 +944,49 @@ msgstr "" #: src/Admin/Settings.php msgid "Reset Data" msgstr "" + +#: src/Admin/Settings.php +msgid "Extension" +msgstr "" + +#. translators: %s: Plugin name +#: src/Admin/Settings.php +msgid "Provided by: %s" +msgstr "" + +#: src/Admin/Settings.php +msgid "No dashboards available." +msgstr "" + +#: src/Admin/Settings.php +msgid "Pre-built dashboards for visualizing your WordPress metrics in Grafana." +msgstr "" + +#: src/Admin/Settings.php +msgid "Installation Instructions" +msgstr "" + +#: src/Admin/Settings.php +msgid "Download the JSON file for your desired dashboard." +msgstr "" + +#: src/Admin/Settings.php +msgid "In Grafana, go to Dashboards → Import." +msgstr "" + +#: src/Admin/Settings.php +msgid "Upload the JSON file or paste its contents." +msgstr "" + +#: src/Admin/Settings.php +msgid "Select your Prometheus data source when prompted." +msgstr "" + +#: src/Admin/Settings.php +msgid "Click Import to create the dashboard." +msgstr "" + +#. translators: %s: Metrics URL +#: src/Admin/Settings.php +msgid "Make sure your Prometheus instance is configured to scrape %s with the correct authentication token." +msgstr "" diff --git a/src/Admin/DashboardProvider.php b/src/Admin/DashboardProvider.php index 59f458f..81b91ff 100644 --- a/src/Admin/DashboardProvider.php +++ b/src/Admin/DashboardProvider.php @@ -16,22 +16,37 @@ if ( ! defined( 'ABSPATH' ) ) { * DashboardProvider class. * * Provides Grafana dashboard templates for download. + * Supports both built-in dashboards and third-party registrations. */ class DashboardProvider { /** - * Dashboard directory path. + * Dashboard directory path for built-in dashboards. * * @var string */ private string $dashboard_dir; /** - * Available dashboard definitions. + * Built-in dashboard definitions. * * @var array */ - private array $dashboards = array(); + private array $builtin_dashboards = array(); + + /** + * Third-party registered dashboard definitions. + * + * @var array + */ + private array $registered_dashboards = array(); + + /** + * Whether the registration hook has been fired. + * + * @var bool + */ + private bool $hook_fired = false; /** * Constructor. @@ -39,43 +54,241 @@ class DashboardProvider { public function __construct() { $this->dashboard_dir = WP_PROMETHEUS_PATH . 'assets/dashboards/'; - $this->dashboards = array( + $this->builtin_dashboards = array( 'wordpress-overview' => array( 'title' => __( 'WordPress Overview', 'wp-prometheus' ), 'description' => __( 'General WordPress metrics including users, posts, comments, and plugins.', 'wp-prometheus' ), 'file' => 'wordpress-overview.json', 'icon' => 'dashicons-wordpress', + 'source' => 'builtin', ), 'wordpress-runtime' => array( 'title' => __( 'Runtime Performance', 'wp-prometheus' ), 'description' => __( 'HTTP request metrics, database query performance, and response times.', 'wp-prometheus' ), 'file' => 'wordpress-runtime.json', 'icon' => 'dashicons-performance', + 'source' => 'builtin', ), 'wordpress-woocommerce' => array( 'title' => __( 'WooCommerce Store', 'wp-prometheus' ), 'description' => __( 'WooCommerce metrics including products, orders, revenue, and customers.', 'wp-prometheus' ), 'file' => 'wordpress-woocommerce.json', 'icon' => 'dashicons-cart', + 'source' => 'builtin', ), ); } + /** + * Register a third-party dashboard. + * + * @param string $slug Dashboard slug (unique identifier). + * @param array $args Dashboard configuration { + * @type string $title Dashboard title (required). + * @type string $description Dashboard description. + * @type string $icon Dashicon class (e.g., 'dashicons-chart-bar'). + * @type string $file Absolute path to JSON file (mutually exclusive with 'json'). + * @type string $json Inline JSON content (mutually exclusive with 'file'). + * @type string $plugin Plugin name for attribution. + * } + * @return bool True if registered successfully, false otherwise. + */ + public function register_dashboard( string $slug, array $args ): bool { + // Sanitize slug - must be valid identifier. + $slug = sanitize_key( $slug ); + + if ( empty( $slug ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'WP Prometheus: Dashboard registration failed - invalid slug' ); + } + return false; + } + + // Check for duplicate slugs (built-in takes precedence). + if ( isset( $this->builtin_dashboards[ $slug ] ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "WP Prometheus: Dashboard slug '$slug' conflicts with built-in dashboard" ); + } + return false; + } + + // Check for duplicate slugs in already registered dashboards. + if ( isset( $this->registered_dashboards[ $slug ] ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "WP Prometheus: Dashboard slug '$slug' already registered" ); + } + return false; + } + + // Validate required fields. + if ( empty( $args['title'] ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "WP Prometheus: Dashboard '$slug' missing required 'title'" ); + } + return false; + } + + // Must have either 'file' or 'json', not both. + $has_file = ! empty( $args['file'] ); + $has_json = ! empty( $args['json'] ); + + if ( ! $has_file && ! $has_json ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "WP Prometheus: Dashboard '$slug' must have 'file' or 'json'" ); + } + return false; + } + + if ( $has_file && $has_json ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "WP Prometheus: Dashboard '$slug' cannot have both 'file' and 'json'" ); + } + return false; + } + + // Validate file path if provided. + if ( $has_file ) { + $file_path = $args['file']; + + // Must be absolute path. + if ( ! path_is_absolute( $file_path ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "WP Prometheus: Dashboard '$slug' file path must be absolute" ); + } + return false; + } + + // File must exist and be readable. + if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "WP Prometheus: Dashboard '$slug' file not found: $file_path" ); + } + return false; + } + + // Security: Prevent path traversal - file must be under wp-content. + $real_path = realpath( $file_path ); + $wp_content_dir = realpath( WP_CONTENT_DIR ); + + if ( false === $real_path || false === $wp_content_dir || + strpos( $real_path, $wp_content_dir ) !== 0 ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "WP Prometheus: Dashboard '$slug' file outside wp-content" ); + } + return false; + } + } + + // Validate JSON if provided inline. + if ( $has_json ) { + $decoded = json_decode( $args['json'], true ); + if ( null === $decoded && json_last_error() !== JSON_ERROR_NONE ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "WP Prometheus: Dashboard '$slug' has invalid JSON" ); + } + return false; + } + } + + // Build dashboard entry. + $this->registered_dashboards[ $slug ] = array( + 'title' => sanitize_text_field( $args['title'] ), + 'description' => sanitize_text_field( $args['description'] ?? '' ), + 'icon' => sanitize_html_class( $args['icon'] ?? 'dashicons-chart-line' ), + 'file' => $has_file ? $file_path : null, + 'json' => $has_json ? $args['json'] : null, + 'plugin' => sanitize_text_field( $args['plugin'] ?? '' ), + 'source' => 'third-party', + ); + + return true; + } + + /** + * Fire the dashboard registration hook with protection. + * + * @return void + */ + private function fire_registration_hook(): void { + if ( $this->hook_fired ) { + return; + } + + $this->hook_fired = true; + + // Check for isolated mode - skip third-party hooks. + $isolated_mode = defined( 'WP_PROMETHEUS_ISOLATED_MODE' ) && WP_PROMETHEUS_ISOLATED_MODE; + + // Also check option for admin-side isolated mode. + if ( ! $isolated_mode ) { + $isolated_mode = (bool) get_option( 'wp_prometheus_isolated_mode', false ); + } + + if ( $isolated_mode ) { + return; + } + + // Use output buffering to capture any accidental output. + ob_start(); + + try { + /** + * Fires to allow third-party plugins to register dashboards. + * + * @since 0.4.6 + * + * @param DashboardProvider $provider The dashboard provider instance. + */ + do_action( 'wp_prometheus_register_dashboards', $this ); + } catch ( \Throwable $e ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'WP Prometheus: Error in dashboard registration hook: ' . $e->getMessage() ); + } + } + + // Discard any output from plugins. + ob_end_clean(); + } + /** * Get list of available dashboards. * * @return array */ public function get_available(): array { + // Fire registration hook first (only once). + $this->fire_registration_hook(); + $available = array(); - foreach ( $this->dashboards as $slug => $dashboard ) { + // Add built-in dashboards (check file exists). + foreach ( $this->builtin_dashboards as $slug => $dashboard ) { $file_path = $this->dashboard_dir . $dashboard['file']; if ( file_exists( $file_path ) ) { $available[ $slug ] = $dashboard; } } + // Add registered third-party dashboards. + foreach ( $this->registered_dashboards as $slug => $dashboard ) { + // Already validated during registration, but double-check. + if ( ! empty( $dashboard['json'] ) || + ( ! empty( $dashboard['file'] ) && file_exists( $dashboard['file'] ) ) ) { + $available[ $slug ] = $dashboard; + } + } + return $available; } @@ -86,35 +299,70 @@ class DashboardProvider { * @return string|null JSON content or null if not found. */ public function get_dashboard( string $slug ): ?string { - // Validate slug to prevent directory traversal. + // Fire registration hook first. + $this->fire_registration_hook(); + + // Validate slug. $slug = sanitize_file_name( $slug ); - if ( ! isset( $this->dashboards[ $slug ] ) ) { - return null; + // Check built-in dashboards first. + if ( isset( $this->builtin_dashboards[ $slug ] ) ) { + $dashboard = $this->builtin_dashboards[ $slug ]; + $file_path = $this->dashboard_dir . $dashboard['file']; + + // Security: Ensure file is within dashboard directory. + $real_path = realpath( $file_path ); + $real_dir = realpath( $this->dashboard_dir ); + + if ( false === $real_path || false === $real_dir || + strpos( $real_path, $real_dir ) !== 0 ) { + return null; + } + + if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) { + return null; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents( $file_path ); + + return false === $content ? null : $content; } - $file_path = $this->dashboard_dir . $this->dashboards[ $slug ]['file']; + // Check registered dashboards. + if ( isset( $this->registered_dashboards[ $slug ] ) ) { + $dashboard = $this->registered_dashboards[ $slug ]; - // Security: Ensure file is within dashboard directory. - $real_path = realpath( $file_path ); - $real_dir = realpath( $this->dashboard_dir ); + // Inline JSON. + if ( ! empty( $dashboard['json'] ) ) { + return $dashboard['json']; + } - if ( false === $real_path || false === $real_dir || strpos( $real_path, $real_dir ) !== 0 ) { - return null; + // File-based. + if ( ! empty( $dashboard['file'] ) ) { + $file_path = $dashboard['file']; + + // Security: Re-verify file is under wp-content. + $real_path = realpath( $file_path ); + $wp_content_dir = realpath( WP_CONTENT_DIR ); + + if ( false === $real_path || false === $wp_content_dir || + strpos( $real_path, $wp_content_dir ) !== 0 ) { + return null; + } + + if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) { + return null; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents( $file_path ); + + return false === $content ? null : $content; + } } - if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) { - return null; - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $content = file_get_contents( $file_path ); - - if ( false === $content ) { - return null; - } - - return $content; + return null; } /** @@ -124,13 +372,20 @@ class DashboardProvider { * @return array|null Dashboard metadata or null if not found. */ public function get_metadata( string $slug ): ?array { + // Fire registration hook first. + $this->fire_registration_hook(); + $slug = sanitize_file_name( $slug ); - if ( ! isset( $this->dashboards[ $slug ] ) ) { - return null; + if ( isset( $this->builtin_dashboards[ $slug ] ) ) { + return $this->builtin_dashboards[ $slug ]; } - return $this->dashboards[ $slug ]; + if ( isset( $this->registered_dashboards[ $slug ] ) ) { + return $this->registered_dashboards[ $slug ]; + } + + return null; } /** @@ -140,12 +395,61 @@ class DashboardProvider { * @return string|null Filename or null if not found. */ public function get_filename( string $slug ): ?string { + // Fire registration hook first. + $this->fire_registration_hook(); + $slug = sanitize_file_name( $slug ); - if ( ! isset( $this->dashboards[ $slug ] ) ) { - return null; + // Built-in dashboards have predefined filenames. + if ( isset( $this->builtin_dashboards[ $slug ] ) ) { + return $this->builtin_dashboards[ $slug ]['file']; } - return $this->dashboards[ $slug ]['file']; + // Registered dashboards - use file basename or generate from slug. + if ( isset( $this->registered_dashboards[ $slug ] ) ) { + $dashboard = $this->registered_dashboards[ $slug ]; + + if ( ! empty( $dashboard['file'] ) ) { + return basename( $dashboard['file'] ); + } + + // Generate filename from slug for inline JSON. + return $slug . '.json'; + } + + return null; + } + + /** + * Check if a dashboard is from a third-party plugin. + * + * @param string $slug Dashboard slug. + * @return bool True if third-party, false if built-in or not found. + */ + public function is_third_party( string $slug ): bool { + $this->fire_registration_hook(); + + $slug = sanitize_file_name( $slug ); + + return isset( $this->registered_dashboards[ $slug ] ); + } + + /** + * Get the plugin name for a third-party dashboard. + * + * @param string $slug Dashboard slug. + * @return string|null Plugin name or null if not found/built-in. + */ + public function get_plugin_name( string $slug ): ?string { + $this->fire_registration_hook(); + + $slug = sanitize_file_name( $slug ); + + if ( isset( $this->registered_dashboards[ $slug ] ) ) { + $plugin = $this->registered_dashboards[ $slug ]['plugin']; + return ! empty( $plugin ) ? $plugin : null; + } + + return null; } } diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php index dd527ac..7ada569 100644 --- a/src/Admin/Settings.php +++ b/src/Admin/Settings.php @@ -1214,20 +1214,45 @@ class Settings {

-
- $dashboard ) : ?> -
-
- + +

+ +
+ $dashboard ) : + $is_third_party = $this->dashboard_provider->is_third_party( $slug ); + $plugin_name = $this->dashboard_provider->get_plugin_name( $slug ); + $card_class = 'wp-prometheus-dashboard-card' . ( $is_third_party ? ' third-party' : '' ); + ?> +
+ + + +
+ +
+

+

+ +

+ + + +

+ +
-

-

- -
- -
+ +
+
diff --git a/wp-prometheus.php b/wp-prometheus.php index 17426e8..b95d33b 100644 --- a/wp-prometheus.php +++ b/wp-prometheus.php @@ -3,7 +3,7 @@ * Plugin Name: WP Prometheus * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-prometheus * Description: Prometheus metrics endpoint for WordPress with extensible hooks for custom metrics. - * Version: 0.4.5 + * Version: 0.4.6 * Requires at least: 6.4 * Requires PHP: 8.3 * Author: Marco Graetsch @@ -169,7 +169,7 @@ wp_prometheus_early_metrics_check(); * * @var string */ -define( 'WP_PROMETHEUS_VERSION', '0.4.5' ); +define( 'WP_PROMETHEUS_VERSION', '0.4.6' ); /** * Plugin file path.