From af952debc4a0007c17a3cc7c30df29d645716550 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:35:02 -0400 Subject: [PATCH 1/4] feat(logging): add an admin dashboard with charts Add a Dashboard submenu under GraphQL Logs that shows, for the last 24 hours, 7 days or 30 days: summary tiles, log entries over time (errors stacked on top), entries by level and the top operations. Aggregation runs in SQL through a new LogStatsInterface with a WordPress database implementation, and results are cached in a short-lived transient. Charts are server-rendered inline SVG with a data table fallback, so no JavaScript or build step is needed. --- .../wp-graphql-logging-dashboard.css | 201 ++++++++ .../src/Admin/Dashboard/TimeSeriesChart.php | 284 +++++++++++ .../src/Admin/DashboardPage.php | 441 ++++++++++++++++++ .../Templates/WPGraphQLLoggerDashboard.php | 225 +++++++++ .../src/Logger/Api/LogStatsInterface.php | 65 +++ .../Database/WordPressDatabaseLogStats.php | 191 ++++++++ plugins/wpgraphql-logging/src/Plugin.php | 2 + .../reset-wpgraphql-logging-settings.php | 5 + .../tests/e2e/specs/dashboard.spec.js | 70 +++ plugins/wpgraphql-logging/tests/e2e/utils.js | 10 + .../WordPressDatabaseLogStatsTest.php | 177 +++++++ 11 files changed, 1671 insertions(+) create mode 100644 plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css create mode 100644 plugins/wpgraphql-logging/src/Admin/Dashboard/TimeSeriesChart.php create mode 100644 plugins/wpgraphql-logging/src/Admin/DashboardPage.php create mode 100644 plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php create mode 100644 plugins/wpgraphql-logging/src/Logger/Api/LogStatsInterface.php create mode 100644 plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogStats.php create mode 100644 plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js create mode 100644 plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogStatsTest.php diff --git a/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css b/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css new file mode 100644 index 000000000..dce1bdc84 --- /dev/null +++ b/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css @@ -0,0 +1,201 @@ +/* WPGraphQL Logging dashboard. Colors follow the WordPress admin palette. */ +.wpgraphql-logging-dashboard { + --wpgraphql-logging-entries: #2271b1; + --wpgraphql-logging-errors: #d63638; + --wpgraphql-logging-warnings: #dba617; + --wpgraphql-logging-grid: #dcdcde; + --wpgraphql-logging-axis: #8c8f94; + --wpgraphql-logging-text-muted: #50575e; + --wpgraphql-logging-surface: #fff; + --wpgraphql-logging-border: #c3c4c7; +} + +.wpgraphql-logging-dashboard__ranges .subsubsub { + margin-top: 12px; +} + +.wpgraphql-logging-dashboard__tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + margin: 16px 0; + padding: 0; + list-style: none; +} + +.wpgraphql-logging-dashboard__tile { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 16px; + background: var(--wpgraphql-logging-surface); + border: 1px solid var(--wpgraphql-logging-border); + border-radius: 4px; +} + +.wpgraphql-logging-dashboard__tile-label { + color: var(--wpgraphql-logging-text-muted); +} + +.wpgraphql-logging-dashboard__tile-value { + font-size: 24px; + font-weight: 600; + line-height: 1.3; + font-variant-numeric: tabular-nums; +} + +.wpgraphql-logging-dashboard__panel { + min-width: 0; + margin: 0 0 16px; + padding: 16px; + background: var(--wpgraphql-logging-surface); + border: 1px solid var(--wpgraphql-logging-border); + border-radius: 4px; +} + +.wpgraphql-logging-dashboard__panel h2 { + margin-top: 0; +} + +.wpgraphql-logging-dashboard__columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; +} + +.wpgraphql-logging-dashboard__legend { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin: 0 0 8px; + padding: 0; + list-style: none; +} + +.wpgraphql-logging-dashboard__legend li { + display: flex; + align-items: center; + gap: 6px; + margin: 0; +} + +.wpgraphql-logging-dashboard__swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; +} + +.wpgraphql-logging-dashboard__swatch--entries, +.wpgraphql-logging-dashboard__bar--entries { + background: var(--wpgraphql-logging-entries); +} + +.wpgraphql-logging-dashboard__swatch--errors, +.wpgraphql-logging-dashboard__bar--errors { + background: var(--wpgraphql-logging-errors); +} + +.wpgraphql-logging-dashboard__bar--warnings { + background: var(--wpgraphql-logging-warnings); +} + +.wpgraphql-logging-chart { + margin: 0; +} + +.wpgraphql-logging-chart__svg { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.wpgraphql-logging-chart__gridline { + stroke: var(--wpgraphql-logging-grid); + stroke-width: 1; +} + +.wpgraphql-logging-chart__axis { + stroke: var(--wpgraphql-logging-axis); + stroke-width: 1; +} + +.wpgraphql-logging-chart__tick { + fill: var(--wpgraphql-logging-text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.wpgraphql-logging-chart__hit { + fill: transparent; +} + +.wpgraphql-logging-chart__slot:hover .wpgraphql-logging-chart__hit { + fill: rgba(0, 0, 0, 0.04); +} + +.wpgraphql-logging-chart__bar--entries { + fill: var(--wpgraphql-logging-entries); +} + +.wpgraphql-logging-chart__bar--errors { + fill: var(--wpgraphql-logging-errors); +} + +.wpgraphql-logging-dashboard__data { + margin-top: 12px; +} + +.wpgraphql-logging-dashboard__data summary { + margin-bottom: 8px; + cursor: pointer; +} + +.wpgraphql-logging-dashboard .num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.wpgraphql-logging-dashboard__bars th[scope="row"] { + word-break: break-word; +} + +.wpgraphql-logging-dashboard__bar-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.wpgraphql-logging-dashboard__bar { + display: inline-block; + flex: 0 1 auto; + min-width: 2px; + max-width: calc(100% - 64px); + height: 12px; + border-radius: 0 4px 4px 0; +} + +.wpgraphql-logging-dashboard__bar-value { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.wpgraphql-logging-dashboard__note { + margin-top: 8px; +} + +/* Windows High Contrast and other forced-color modes: keep marks visible; the legend and data table carry identity. */ +@media (forced-colors: active) { + .wpgraphql-logging-chart__bar--entries, + .wpgraphql-logging-chart__bar--errors { + fill: CanvasText; + } + + .wpgraphql-logging-dashboard__bar, + .wpgraphql-logging-dashboard__swatch { + forced-color-adjust: none; + background: CanvasText; + } +} diff --git a/plugins/wpgraphql-logging/src/Admin/Dashboard/TimeSeriesChart.php b/plugins/wpgraphql-logging/src/Admin/Dashboard/TimeSeriesChart.php new file mode 100644 index 000000000..cd0de80eb --- /dev/null +++ b/plugins/wpgraphql-logging/src/Admin/Dashboard/TimeSeriesChart.php @@ -0,0 +1,284 @@ + for hover, and the dashboard template pairs the chart with a data table. + * + * @package WPGraphQL\Logging + * + * @since 1.1.0 + */ +class TimeSeriesChart { + /** + * SVG viewBox width. + * + * @var int + */ + public const WIDTH = 720; + + /** + * SVG viewBox height. + * + * @var int + */ + public const HEIGHT = 240; + + /** + * Plot margins: top, right, bottom, left. + * + * @var array + */ + protected const MARGIN = [ 12.0, 8.0, 28.0, 48.0 ]; + + /** + * Maximum column width so a short range (e.g. 7 days) does not draw slabs. + * + * @var float + */ + protected const MAX_BAR_WIDTH = 40.0; + + /** + * Gap in pixels between stacked segments. + * + * @var float + */ + protected const SEGMENT_GAP = 2.0; + + /** + * Corner radius for the top (data end) of each column. + * + * @var float + */ + protected const RADIUS = 4.0; + + /** + * Outputs the chart SVG. + * + * @param array $points The data points, oldest first. + * @param string $id Unique ID prefix for the SVG title/description. + * @param string $title Accessible chart title. + * @param string $description Accessible chart description. + */ + public function render( array $points, string $id, string $title, string $description ): void { + $points = array_values( $points ); + $max_value = 0; + foreach ( $points as $point ) { + $max_value = max( $max_value, $point['total'] ); + } + [ $axis_max, $step ] = self::nice_scale( $max_value ); + + printf( + '', + esc_attr( self::num( self::WIDTH ) ), + esc_attr( self::num( self::HEIGHT ) ), + esc_attr( $id ) + ); + printf( '%2$s', esc_attr( $id ), esc_html( $title ) ); + printf( '%2$s', esc_attr( $id ), esc_html( $description ) ); + + $this->render_grid( $axis_max, $step ); + + $count = max( 1, count( $points ) ); + $label_step = (int) max( 1, ceil( $count / 8 ) ); + foreach ( $points as $index => $point ) { + $this->render_column( $point, $index, $count, $axis_max, 0 === $index % $label_step ); + } + + echo ''; + } + + /** + * Computes a "nice" axis maximum and tick step for a maximum data value. + * + * @param int $max_value The largest value to plot. + * + * @return array{0: int, 1: int} The axis maximum and the tick step. + */ + public static function nice_scale( int $max_value ): array { + if ( $max_value <= 0 ) { + return [ 4, 1 ]; + } + + $raw_step = max( 1.0, (float) $max_value / 4.0 ); + $magnitude = 10.0 ** floor( log10( $raw_step ) ); + $residual = $raw_step / $magnitude; + + // Pick the smallest of 1, 2, 5 or 10 (times the magnitude) that covers the raw step. + $nice = 10.0; + foreach ( [ 1.0, 2.0, 5.0 ] as $candidate ) { + if ( $residual <= $candidate ) { + $nice = $candidate; + break; + } + } + + $step = max( 1, (int) round( $nice * $magnitude ) ); + + return [ (int) ceil( $max_value / $step ) * $step, $step ]; + } + + /** + * Outputs recessive horizontal grid lines and y-axis labels. + * + * @param int $axis_max The axis maximum. + * @param int $step The tick step. + */ + protected function render_grid( int $axis_max, int $step ): void { + $plot = self::get_plot_area(); + + echo ''; + } + + /** + * Outputs one stacked column: non-error entries at the baseline, errors on top. + * + * @param array{label: string, full_label: string, total: int, errors: int} $point The data point. + * @param int $index The column index. + * @param int $count The number of columns. + * @param int $axis_max The axis maximum. + * @param bool $show_label Whether to draw the x-axis label. + */ + protected function render_column( array $point, int $index, int $count, int $axis_max, bool $show_label ): void { + $plot = self::get_plot_area(); + $slot_width = $plot['width'] / (float) $count; + $bar_width = min( self::MAX_BAR_WIDTH, max( 2.0, $slot_width - max( 2.0, $slot_width * 0.3 ) ) ); + $slot_x = $plot['left'] + ( (float) $index * $slot_width ); + $bar_x = $slot_x + ( ( $slot_width - $bar_width ) / 2.0 ); + $errors = max( 0, $point['errors'] ); + $others = max( 0, $point['total'] - $errors ); + $scale = $plot['height'] / (float) $axis_max; + + echo ''; + printf( + '%s', + esc_html( + sprintf( + /* translators: 1: date/time label, 2: number of log entries, 3: number of error entries. */ + __( '%1$s: %2$s entries, %3$s errors', 'wpgraphql-logging' ), + $point['full_label'], + number_format_i18n( $point['total'] ), + number_format_i18n( $errors ) + ) + ) + ); + + // Hit target covers the full slot so small columns are still easy to hover. + printf( + '', + esc_attr( self::num( $slot_x ) ), + esc_attr( self::num( $plot['top'] ) ), + esc_attr( self::num( $slot_width ) ), + esc_attr( self::num( $plot['height'] ) ) + ); + + $others_height = (float) $others * $scale; + if ( $others > 0 ) { + $this->render_segment( 'wpgraphql-logging-chart__bar--entries', $bar_x, $plot['baseline'] - $others_height, $bar_width, $others_height, 0 === $errors ); + } + if ( $errors > 0 ) { + $gap = $others > 0 ? self::SEGMENT_GAP : 0.0; + $errors_height = max( 1.0, ( (float) $errors * $scale ) - $gap ); + $this->render_segment( 'wpgraphql-logging-chart__bar--errors', $bar_x, $plot['baseline'] - $others_height - $gap - $errors_height, $bar_width, $errors_height, true ); + } + + if ( $show_label ) { + printf( + '', + esc_attr( self::num( $slot_x + ( $slot_width / 2.0 ) ) ), + esc_attr( self::num( $plot['baseline'] + 18.0 ) ), + esc_html( $point['label'] ) + ); + } + echo ''; + } + + /** + * Outputs one column segment. The top segment gets rounded top corners; the baseline stays square. + * + * @param string $css_class The segment CSS class. + * @param float $x Left edge. + * @param float $y Top edge. + * @param float $width Segment width. + * @param float $height Segment height. + * @param bool $is_top Whether this is the topmost segment of the column. + */ + protected function render_segment( string $css_class, float $x, float $y, float $width, float $height, bool $is_top ): void { + $radius = $is_top ? min( self::RADIUS, $width / 2.0, $height ) : 0.0; + + if ( $radius <= 0 ) { + printf( + '', + esc_attr( $css_class ), + esc_attr( self::num( $x ) ), + esc_attr( self::num( $y ) ), + esc_attr( self::num( $width ) ), + esc_attr( self::num( $height ) ) + ); + return; + } + + $path = sprintf( + 'M%1$s %2$s V%3$s Q%1$s %4$s %5$s %4$s H%6$s Q%7$s %4$s %7$s %3$s V%2$s Z', + self::num( $x ), + self::num( $y + $height ), + self::num( $y + $radius ), + self::num( $y ), + self::num( $x + $radius ), + self::num( $x + $width - $radius ), + self::num( $x + $width ) + ); + printf( '', esc_attr( $css_class ), esc_attr( $path ) ); + } + + /** + * Gets the plot area geometry inside the margins. + * + * @return array{top: float, left: float, width: float, height: float, baseline: float} + */ + protected static function get_plot_area(): array { + [ $top, $right, $bottom, $left ] = self::MARGIN; + + $height = (float) self::HEIGHT - $top - $bottom; + + return [ + 'top' => $top, + 'left' => $left, + 'width' => (float) self::WIDTH - $left - $right, + 'height' => $height, + 'baseline' => $top + $height, + ]; + } + + /** + * Formats a coordinate with at most two decimals using a locale-independent decimal point. + * + * @param float|int $value The value to format. + */ + protected static function num( float|int $value ): string { + $formatted = rtrim( rtrim( number_format( (float) $value, 2, '.', '' ), '0' ), '.' ); + return ( '' === $formatted || '-0' === $formatted ) ? '0' : $formatted; + } +} diff --git a/plugins/wpgraphql-logging/src/Admin/DashboardPage.php b/plugins/wpgraphql-logging/src/Admin/DashboardPage.php new file mode 100644 index 000000000..ad01083c9 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Admin/DashboardPage.php @@ -0,0 +1,441 @@ +setup(); + } + + /** + * Fires after the dashboard page is initialized. + * + * @param \WPGraphQL\Logging\Admin\DashboardPage $instance The dashboard page instance. + */ + do_action( 'wpgraphql_logging_dashboard_init', self::$instance ); + + return self::$instance; + } + + /** + * Sets up the dashboard page hooks. + */ + public function setup(): void { + // Priority 10 runs after the logs menu is registered (same priority, registered earlier) and before Settings (11). + add_action( 'admin_menu', [ $this, 'register_page' ], 10, 0 ); + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_styles' ], 10, 1 ); + } + + /** + * Registers the dashboard submenu page. + */ + public function register_page(): void { + $page_hook = add_submenu_page( + ViewLogsPage::ADMIN_PAGE_SLUG, + esc_html__( 'WPGraphQL Logging Dashboard', 'wpgraphql-logging' ), + esc_html__( 'Dashboard', 'wpgraphql-logging' ), + 'manage_options', + self::ADMIN_PAGE_SLUG, + [ $this, 'render_page' ] + ); + + $this->page_hook = is_string( $page_hook ) ? $page_hook : ''; + } + + /** + * Enqueues the dashboard stylesheet on the dashboard page only. + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_admin_styles( string $hook_suffix ): void { + if ( '' === $this->page_hook || $hook_suffix !== $this->page_hook ) { + return; + } + + $relative_path = 'assets/css/dashboard/wp-graphql-logging-dashboard.css'; + if ( file_exists( trailingslashit( WPGRAPHQL_LOGGING_PLUGIN_DIR ) . $relative_path ) ) { + wp_enqueue_style( + 'wpgraphql-logging-dashboard', + trailingslashit( WPGRAPHQL_LOGGING_PLUGIN_URL ) . $relative_path, + [], + WPGRAPHQL_LOGGING_VERSION + ); + } + + /** + * Fires after the dashboard styles are enqueued. + * + * @param string $hook_suffix The current admin page hook suffix. + */ + do_action( 'wpgraphql_logging_dashboard_admin_enqueue_scripts', $hook_suffix ); + } + + /** + * Gets the available time ranges. + * + * @return array Ranges keyed by slug. + */ + public function get_ranges(): array { + $ranges = [ + '24h' => [ + 'label' => __( 'Last 24 hours', 'wpgraphql-logging' ), + 'bucket_seconds' => HOUR_IN_SECONDS, + 'bucket_count' => 24, + ], + '7d' => [ + 'label' => __( 'Last 7 days', 'wpgraphql-logging' ), + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 7, + ], + '30d' => [ + 'label' => __( 'Last 30 days', 'wpgraphql-logging' ), + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 30, + ], + ]; + + /** + * Filters the dashboard time ranges. + * + * @param array $ranges Ranges keyed by slug. + */ + $filtered = apply_filters( 'wpgraphql_logging_dashboard_ranges', $ranges ); + if ( ! is_array( $filtered ) ) { + return $ranges; + } + + // Drop malformed ranges added by filters. + $valid = []; + foreach ( $filtered as $slug => $range ) { + if ( ! is_string( $slug ) || ! $this->is_valid_range( $slug, $range ) ) { + continue; + } + /** @var array{label: string, bucket_seconds: int, bucket_count: int} $range */ + $valid[ $slug ] = [ + 'label' => $range['label'], + 'bucket_seconds' => $range['bucket_seconds'], + 'bucket_count' => $range['bucket_count'], + ]; + } + + return empty( $valid ) ? $ranges : $valid; + } + + /** + * Gets the selected time range slug from the request, falling back to the default. + * + * This is a read-only view, so no nonce is required; the value is validated against an allow list. + */ + public function get_current_range(): string { + $ranges = $this->get_ranges(); + $default = array_key_exists( self::DEFAULT_RANGE, $ranges ) ? self::DEFAULT_RANGE : (string) array_key_first( $ranges ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['range'] ) || ! is_string( $_GET['range'] ) ) { + return $default; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $range = sanitize_key( wp_unslash( $_GET['range'] ) ); + + return array_key_exists( $range, $ranges ) ? $range : $default; + } + + /** + * Computes the UTC start of the window for a range. + * + * Buckets are aligned to the site time zone: hourly ranges start at the top of the hour and + * daily ranges start at local midnight, so the last bucket always contains "now". + * + * @param int $bucket_seconds Bucket size in seconds. + * @param int $bucket_count Number of buckets. + * @param \DateTimeImmutable|null $now The current time (for testing). + */ + public function get_window_start( int $bucket_seconds, int $bucket_count, ?DateTimeImmutable $now = null ): DateTimeImmutable { + $now = ( $now ?? new DateTimeImmutable( 'now' ) )->setTimezone( wp_timezone() ); + + if ( DAY_IN_SECONDS === $bucket_seconds ) { + // Step back in calendar days so the window starts at local midnight even across DST changes. + $start = $now->setTime( 0, 0 )->modify( sprintf( '-%d days', $bucket_count - 1 ) )->getTimestamp(); + return ( new DateTimeImmutable( '@' . $start ) )->setTimezone( new DateTimeZone( 'UTC' ) ); + } + + $bucket_seconds = max( 1, $bucket_seconds ); + $aligned = HOUR_IN_SECONDS === $bucket_seconds + ? $now->setTime( (int) $now->format( 'G' ), 0 )->getTimestamp() + : $now->getTimestamp() - ( $now->getTimestamp() % $bucket_seconds ); + $start = $aligned - ( $bucket_seconds * ( $bucket_count - 1 ) ); + + return ( new DateTimeImmutable( '@' . $start ) )->setTimezone( new DateTimeZone( 'UTC' ) ); + } + + /** + * Gets the stats service used to aggregate log data. + * + * Returns null when the log store has been replaced and no stats service was provided for it. + */ + public function get_stats_service(): ?LogStatsInterface { + $default = LogStoreService::get_log_service() instanceof WordPressDatabaseLogService + ? new WordPressDatabaseLogStats() + : null; + + /** + * Filters the stats service used by the dashboard. + * + * Return an implementation of LogStatsInterface to support a custom log store. + * + * @param \WPGraphQL\Logging\Logger\Api\LogStatsInterface|null $default The default stats service. + */ + $service = apply_filters( 'wpgraphql_logging_dashboard_stats_service', $default ); + + return $service instanceof LogStatsInterface ? $service : null; + } + + /** + * Gets the dashboard data for a range, using a short-lived transient cache. + * + * @param string $range The range slug. + * + * @return array{range: string, start: int, end: int, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array}|null + */ + public function get_dashboard_data( string $range ): ?array { + $ranges = $this->get_ranges(); + if ( ! array_key_exists( $range, $ranges ) ) { + return null; + } + + $stats = $this->get_stats_service(); + if ( null === $stats ) { + return null; + } + + $cache_key = self::CACHE_KEY_PREFIX . $range; + $cached = get_transient( $cache_key ); + if ( is_array( $cached ) && isset( $cached['range'] ) && $range === $cached['range'] ) { + /** @var array{range: string, start: int, end: int, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array} $cached */ + return $cached; + } + + $bucket_seconds = $ranges[ $range ]['bucket_seconds']; + $bucket_count = $ranges[ $range ]['bucket_count']; + $start = $this->get_window_start( $bucket_seconds, $bucket_count ); + $end = $start->setTimestamp( $start->getTimestamp() + ( $bucket_seconds * $bucket_count ) ); + + $data = [ + 'range' => $range, + 'start' => $start->getTimestamp(), + 'end' => $end->getTimestamp(), + 'summary' => $stats->get_summary( $start, $end ), + 'series' => $this->label_series( $stats->get_time_series( $start, $bucket_seconds, $bucket_count ), $start->getTimestamp(), $bucket_seconds ), + 'levels' => $stats->get_level_breakdown( $start, $end ), + 'operations' => $stats->get_top_operations( $start, $end, self::TOP_OPERATIONS_LIMIT ), + ]; + + /** + * Filters the dashboard data before it is cached and rendered. + * + * @param array $data The dashboard data. + * @param string $range The range slug. + */ + $data = apply_filters( 'wpgraphql_logging_dashboard_data', $data, $range ); + + /** + * Filters how long dashboard data is cached, in seconds. Return 0 to disable caching. + * + * @param int $ttl Cache lifetime in seconds. + * @param string $range The range slug. + */ + $ttl = (int) apply_filters( 'wpgraphql_logging_dashboard_cache_ttl', self::CACHE_TTL, $range ); + if ( $ttl > 0 ) { + set_transient( $cache_key, $data, $ttl ); + } + + return $data; + } + + /** + * Renders the dashboard page. + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wpgraphql-logging' ) ); + } + + $ranges = $this->get_ranges(); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $current_range = $this->get_current_range(); + $data = $this->get_dashboard_data( $current_range ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $chart = new TimeSeriesChart(); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $base_url = menu_page_url( self::ADMIN_PAGE_SLUG, false ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $logs_url = menu_page_url( ViewLogsPage::ADMIN_PAGE_SLUG, false ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + + $template = (string) apply_filters( 'wpgraphql_logging_dashboard_template_path', __DIR__ . '/View/Templates/WPGraphQLLoggerDashboard.php' ); + if ( ! file_exists( $template ) ) { + return; + } + + require $template; // @phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable + } + + /** + * Returns a part of a whole as a percentage, or 0 when the whole is empty. + * + * @param int $part The part. + * @param int $whole The whole. + */ + public static function percent( int $part, int $whole ): float { + if ( $whole <= 0 ) { + return 0.0; + } + + return ( (float) $part / (float) $whole ) * 100.0; + } + + /** + * Maps a Monolog level to the CSS modifier used for its bar: errors, warnings or entries. + * + * @param int $level The Monolog level. + */ + public static function get_level_modifier( int $level ): string { + if ( $level >= WordPressDatabaseLogStats::ERROR_LEVEL ) { + return 'errors'; + } + + return $level >= self::WARNING_LEVEL ? 'warnings' : 'entries'; + } + + /** + * Checks that a filtered range has a key-safe slug, a label and positive integer bucket settings. + * + * @param string $slug The range slug. + * @param mixed $range The range definition. + */ + protected function is_valid_range( string $slug, $range ): bool { + if ( sanitize_key( $slug ) !== $slug || ! is_array( $range ) ) { + return false; + } + + $label = $range['label'] ?? null; + $bucket_seconds = $range['bucket_seconds'] ?? null; + $bucket_count = $range['bucket_count'] ?? null; + + return is_string( $label ) && is_int( $bucket_seconds ) && is_int( $bucket_count ) && $bucket_seconds > 0 && $bucket_count > 0; + } + + /** + * Adds display labels to time series buckets. + * + * @param array $series Counts keyed by bucket index. + * @param int $start Window start as a Unix timestamp. + * @param int $bucket_seconds Bucket size in seconds. + * + * @return array + */ + protected function label_series( array $series, int $start, int $bucket_seconds ): array { + $daily = $bucket_seconds >= DAY_IN_SECONDS; + $date_format = (string) get_option( 'date_format', 'F j, Y' ); + $time_format = (string) get_option( 'time_format', 'g:i a' ); + $labelled = []; + + ksort( $series ); + foreach ( $series as $index => $counts ) { + $timestamp = $start + ( $index * $bucket_seconds ); + if ( $daily ) { + // Label daily buckets by their midpoint so a DST shift cannot push the label onto the previous day. + $timestamp += intdiv( $bucket_seconds, 2 ); + } + $labelled[] = [ + 'label' => (string) wp_date( $daily ? 'M j' : $time_format, $timestamp ), + 'full_label' => (string) wp_date( $daily ? $date_format : $date_format . ' ' . $time_format, $timestamp ), + 'total' => (int) $counts['total'], + 'errors' => (int) $counts['errors'], + ]; + } + + return $labelled; + } +} diff --git a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php new file mode 100644 index 000000000..475dc04b7 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php @@ -0,0 +1,225 @@ + $ranges + * @var string $current_range + * @var array{range: string, start: int, end: int, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array}|null $data + * @var \WPGraphQL\Logging\Admin\Dashboard\TimeSeriesChart $chart + * @var string $base_url + * @var string $logs_url + * + * @since 1.1.0 + * + * @phpcs:disable HWPStandard.ControlStructures.ElseKeyword + */ + + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +$wpgraphql_logging_range_label = $ranges[ $current_range ]['label'] ?? ''; +?> +
+

+ +
+ + +
+ + +
+

+
+ + +
    +
  • + + +
  • +
  • + + +
  • +
  • + + + + +
  • +
  • + + +
  • +
+ + +
+

+
+ + +
+

+
    +
  • +
  • +
+
+ render( + $data['series'], + 'wpgraphql-logging-entries-chart', + /* translators: %s: time range label, e.g. "Last 7 days". */ + sprintf( __( 'Log entries over time, %s', 'wpgraphql-logging' ), $wpgraphql_logging_range_label ), + sprintf( + /* translators: 1: total number of log entries, 2: number of error entries. */ + __( 'Stacked column chart. %1$s entries in total, of which %2$s are errors. A data table follows.', 'wpgraphql-logging' ), + number_format_i18n( $wpgraphql_logging_summary['total'] ), + number_format_i18n( $wpgraphql_logging_summary['errors'] ) + ) + ); + ?> +
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+

+ +

+ + + + + + + + + + + + + + + + + +
+ + + + +
+ +
+ +
+

+ +

+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ +
+
+ +

+ +

+ +
diff --git a/plugins/wpgraphql-logging/src/Logger/Api/LogStatsInterface.php b/plugins/wpgraphql-logging/src/Logger/Api/LogStatsInterface.php new file mode 100644 index 000000000..fd6e2b3da --- /dev/null +++ b/plugins/wpgraphql-logging/src/Logger/Api/LogStatsInterface.php @@ -0,0 +1,65 @@ + Counts keyed by bucket index (0 to $bucket_count - 1). + */ + public function get_time_series( DateTimeInterface $start, int $bucket_seconds, int $bucket_count ): array; + + /** + * Gets log entry counts grouped by log level. + * + * @param \DateTimeInterface $start Start of the window (inclusive, UTC). + * @param \DateTimeInterface $end End of the window (exclusive, UTC). + * + * @return array Rows ordered by level, highest first. + */ + public function get_level_breakdown( DateTimeInterface $start, DateTimeInterface $end ): array; + + /** + * Gets the GraphQL operations with the most log entries. + * + * Entries without an operation name are grouped under an empty string. + * + * @param \DateTimeInterface $start Start of the window (inclusive, UTC). + * @param \DateTimeInterface $end End of the window (exclusive, UTC). + * @param int $limit Maximum number of operations to return. + * + * @return array Rows ordered by total, highest first. + */ + public function get_top_operations( DateTimeInterface $start, DateTimeInterface $end, int $limit = 10 ): array; +} diff --git a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogStats.php b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogStats.php new file mode 100644 index 000000000..be254952c --- /dev/null +++ b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogStats.php @@ -0,0 +1,191 @@ += %d THEN 1 ELSE 0 END) AS errors,' + . ' COUNT(DISTINCT NULLIF(' . self::OPERATION_SQL . ", '')) AS operations" + . ' FROM %i WHERE datetime >= %s AND datetime < %s'; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The only interpolated value is a class constant. + $row = $wpdb->get_row( $wpdb->prepare( $sql, self::ERROR_LEVEL, $this->get_table_name(), $this->format_date( $start ), $this->format_date( $end ) ), ARRAY_A ); + + return [ + 'total' => (int) ( $row['total'] ?? 0 ), + 'errors' => (int) ( $row['errors'] ?? 0 ), + 'operations' => (int) ( $row['operations'] ?? 0 ), + ]; + } + + /** + * {@inheritDoc} + */ + public function get_time_series( DateTimeInterface $start, int $bucket_seconds, int $bucket_count ): array { + global $wpdb; + + $bucket_seconds = max( 1, $bucket_seconds ); + $bucket_count = max( 1, $bucket_count ); + $start_date = $this->format_date( $start ); + $end_date = gmdate( 'Y-m-d H:i:s', $start->getTimestamp() + ( $bucket_seconds * $bucket_count ) ); + + // TIMESTAMPDIFF between two naive DATETIME values is independent of the MySQL session time zone. + $results = $wpdb->get_results( + $wpdb->prepare( + 'SELECT FLOOR(TIMESTAMPDIFF(SECOND, %s, datetime) / %d) AS bucket,' + . ' COUNT(*) AS total,' + . ' SUM(CASE WHEN level >= %d THEN 1 ELSE 0 END) AS errors' + . ' FROM %i WHERE datetime >= %s AND datetime < %s' + . ' GROUP BY bucket ORDER BY bucket', + $start_date, + $bucket_seconds, + self::ERROR_LEVEL, + $this->get_table_name(), + $start_date, + $end_date + ), + ARRAY_A + ); + + $series = array_fill( 0, $bucket_count, [ + 'total' => 0, + 'errors' => 0, + ] ); + + if ( ! is_array( $results ) ) { + return $series; + } + + foreach ( $results as $row ) { + $bucket = (int) $row['bucket']; + if ( $bucket < 0 || $bucket >= $bucket_count ) { + continue; + } + $series[ $bucket ] = [ + 'total' => (int) $row['total'], + 'errors' => (int) $row['errors'], + ]; + } + + return $series; + } + + /** + * {@inheritDoc} + */ + public function get_level_breakdown( DateTimeInterface $start, DateTimeInterface $end ): array { + global $wpdb; + + $results = $wpdb->get_results( + $wpdb->prepare( + 'SELECT level, level_name, COUNT(*) AS total FROM %i' + . ' WHERE datetime >= %s AND datetime < %s' + . ' GROUP BY level, level_name ORDER BY level DESC', + $this->get_table_name(), + $this->format_date( $start ), + $this->format_date( $end ) + ), + ARRAY_A + ); + + if ( ! is_array( $results ) ) { + return []; + } + + return array_map( + static fn ( array $row ): array => [ + 'level' => (int) $row['level'], + 'level_name' => (string) $row['level_name'], + 'total' => (int) $row['total'], + ], + $results + ); + } + + /** + * {@inheritDoc} + */ + public function get_top_operations( DateTimeInterface $start, DateTimeInterface $end, int $limit = 10 ): array { + global $wpdb; + + $sql = 'SELECT ' . self::OPERATION_SQL . ' AS operation,' + . ' COUNT(*) AS total,' + . ' SUM(CASE WHEN level >= %d THEN 1 ELSE 0 END) AS errors' + . ' FROM %i WHERE datetime >= %s AND datetime < %s' + . ' GROUP BY operation ORDER BY total DESC, operation ASC LIMIT %d'; + + $results = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The only interpolated value is a class constant. + $wpdb->prepare( $sql, self::ERROR_LEVEL, $this->get_table_name(), $this->format_date( $start ), $this->format_date( $end ), max( 1, $limit ) ), + ARRAY_A + ); + + if ( ! is_array( $results ) ) { + return []; + } + + return array_map( + static fn ( array $row ): array => [ + 'operation' => (string) $row['operation'], + 'total' => (int) $row['total'], + 'errors' => (int) $row['errors'], + ], + $results + ); + } + + /** + * Gets the log table name. + */ + protected function get_table_name(): string { + return WordPressDatabaseEntity::get_table_name(); + } + + /** + * Formats a date as a UTC MySQL DATETIME string. + * + * @param \DateTimeInterface $date The date to format. + */ + protected function format_date( DateTimeInterface $date ): string { + return gmdate( 'Y-m-d H:i:s', $date->getTimestamp() ); + } +} diff --git a/plugins/wpgraphql-logging/src/Plugin.php b/plugins/wpgraphql-logging/src/Plugin.php index 298642a08..4c0313555 100644 --- a/plugins/wpgraphql-logging/src/Plugin.php +++ b/plugins/wpgraphql-logging/src/Plugin.php @@ -5,6 +5,7 @@ namespace WPGraphQL\Logging; use WPGraphQL\Logging\Admin\AdminNotice; +use WPGraphQL\Logging\Admin\DashboardPage; use WPGraphQL\Logging\Admin\Settings\ConfigurationHelper; use WPGraphQL\Logging\Admin\Settings\Fields\Tab\BasicConfigurationTab; use WPGraphQL\Logging\Admin\Settings\Fields\Tab\DataManagementTab; @@ -68,6 +69,7 @@ public function setup(): void { ConfigurationHelper::register_cache_hooks(); // Register cache hooks. SettingsPage::init(); // Settings page. ViewLogsPage::init(); // View logs page. + DashboardPage::init(); // Dashboard page. AdminNotice::init(); // Admin notices. } diff --git a/plugins/wpgraphql-logging/tests/e2e/plugins/reset-wpgraphql-logging-settings/reset-wpgraphql-logging-settings.php b/plugins/wpgraphql-logging/tests/e2e/plugins/reset-wpgraphql-logging-settings/reset-wpgraphql-logging-settings.php index aca0a2554..5f60e7bf7 100644 --- a/plugins/wpgraphql-logging/tests/e2e/plugins/reset-wpgraphql-logging-settings/reset-wpgraphql-logging-settings.php +++ b/plugins/wpgraphql-logging/tests/e2e/plugins/reset-wpgraphql-logging-settings/reset-wpgraphql-logging-settings.php @@ -15,6 +15,11 @@ $table_name = $wpdb->prefix . 'wpgraphql_logging'; $wpdb->query("TRUNCATE TABLE {$table_name}"); + // Clear cached dashboard figures + foreach (array('24h', '7d', '30d') as $range) { + delete_transient('wpgraphql_logging_dashboard_' . $range); + } + // Remove admin notice dismissed meta data delete_user_meta(get_current_user_id(), 'wpgraphql-logging-admin-notice'); diff --git a/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js b/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js new file mode 100644 index 000000000..c462fc872 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js @@ -0,0 +1,70 @@ +import { expect, test } from "@wordpress/e2e-test-utils-playwright"; +import { + goToLoggingSettingsPage, + goToDashboardPage, + configureLogging, + executeGraphQLQuery, + resetPluginSettings, +} from "../utils"; +import { GET_POSTS_QUERY } from "../constants"; + +test.describe("Logging Dashboard", () => { + test.beforeEach(async ({ admin }) => { + await resetPluginSettings(admin); + }); + + test("is listed in the GraphQL Logs menu", async ({ page, admin }) => { + await admin.visitAdminPage("/index.php"); + const menuLink = page.locator( + '#adminmenu a[href="admin.php?page=wpgraphql-logging-dashboard"]' + ); + await expect(menuLink).toHaveText("Dashboard"); + }); + + test("loads with summary tiles, chart and range links", async ({ + page, + admin, + }) => { + await goToDashboardPage(admin); + + await expect(page.locator("h1")).toHaveText("WPGraphQL Logging Dashboard"); + await expect(page.locator(".wpgraphql-logging-dashboard__tile")).toHaveCount(4); + await expect( + page.locator('svg.wpgraphql-logging-chart__svg[role="img"]') + ).toBeVisible(); + + // Default range is the last 7 days. + await expect( + page.locator('.wpgraphql-logging-dashboard__ranges a[aria-current="page"]') + ).toHaveText("Last 7 days"); + + await page + .locator(".wpgraphql-logging-dashboard__ranges a") + .filter({ hasText: "Last 24 hours" }) + .click(); + await expect(page).toHaveURL(/range=24h/); + await expect( + page.locator('.wpgraphql-logging-dashboard__ranges a[aria-current="page"]') + ).toHaveText("Last 24 hours"); + }); + + test("shows logged operations", async ({ page, admin, request }) => { + await goToLoggingSettingsPage(admin); + await configureLogging(page, { + enabled: true, + dataSampling: "100", + eventLogSelection: ["graphql_request_results"], + }); + + const response = await executeGraphQLQuery(request, GET_POSTS_QUERY); + expect(response.ok()).toBeTruthy(); + + // The reset helper clears cached dashboard figures, so the new entry shows up straight away. + await goToDashboardPage(admin); + + const operationsPanel = page.locator( + 'section[aria-labelledby="wpgraphql-logging-operations-heading"]' + ); + await expect(operationsPanel.locator("code", { hasText: "GetPosts" })).toBeVisible(); + }); +}); diff --git a/plugins/wpgraphql-logging/tests/e2e/utils.js b/plugins/wpgraphql-logging/tests/e2e/utils.js index b7c3e12d1..259f2e0d6 100644 --- a/plugins/wpgraphql-logging/tests/e2e/utils.js +++ b/plugins/wpgraphql-logging/tests/e2e/utils.js @@ -21,6 +21,16 @@ export async function goToLogsListPage(admin) { await admin.visitAdminPage("/admin.php?page=wpgraphql-logging-view"); } +/** + * Navigate to the WPGraphQL Logging dashboard page + */ +export async function goToDashboardPage(admin, range = "") { + const query = range ? `&range=${range}` : ""; + await admin.visitAdminPage( + `/admin.php?page=wpgraphql-logging-dashboard${query}` + ); +} + /** * Configure logging settings with common options */ diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogStatsTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogStatsTest.php new file mode 100644 index 000000000..e51f3f6eb --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogStatsTest.php @@ -0,0 +1,177 @@ +log_service = new WordPressDatabaseLogService(); + $this->log_service->activate(); + $this->log_service->delete_all_entities(); + $this->stats = new WordPressDatabaseLogStats(); + $this->start = new DateTimeImmutable( '2026-01-01 00:00:00', new DateTimeZone( 'UTC' ) ); + } + + protected function tearDown(): void { + $this->log_service->delete_all_entities(); + parent::tearDown(); + } + + /** + * Inserts a log row with an explicit datetime. + * + * @param string $datetime UTC datetime. + * @param int $level Monolog level. + * @param string $level_name Level name. + * @param string|null $operation Operation name stored in context.operation_name. + */ + private function insert_row( string $datetime, int $level = 200, string $level_name = 'INFO', ?string $operation = 'GetPosts' ): void { + global $wpdb; + $wpdb->insert( + WordPressDatabaseEntity::get_table_name(), + [ + 'channel' => 'wpgraphql_logging', + 'level' => $level, + 'level_name' => $level_name, + 'message' => 'WPGraphQL Response', + 'context' => wp_json_encode( [ 'operation_name' => $operation ] ), + 'extra' => wp_json_encode( [] ), + 'datetime' => $datetime, + ] + ); + } + + private function end( int $hours ): DateTimeImmutable { + return $this->start->modify( sprintf( '+%d hours', $hours ) ); + } + + public function test_summary_is_zero_for_empty_table(): void { + $summary = $this->stats->get_summary( $this->start, $this->end( 24 ) ); + $this->assertSame( [ 'total' => 0, 'errors' => 0, 'operations' => 0 ], $summary ); + } + + public function test_summary_counts_entries_errors_and_distinct_operations(): void { + $this->insert_row( '2026-01-01 01:00:00' ); + $this->insert_row( '2026-01-01 02:00:00', 400, 'ERROR' ); + $this->insert_row( '2026-01-01 03:00:00', 500, 'CRITICAL', 'GetPage' ); + $this->insert_row( '2026-01-01 04:00:00', 300, 'WARNING', null ); + // Outside the window. + $this->insert_row( '2025-12-31 23:59:59', 400, 'ERROR', 'Old' ); + $this->insert_row( '2026-01-02 00:00:00', 400, 'ERROR', 'Later' ); + + $summary = $this->stats->get_summary( $this->start, $this->end( 24 ) ); + + $this->assertSame( 4, $summary['total'] ); + $this->assertSame( 2, $summary['errors'], 'ERROR and above count as errors; WARNING does not.' ); + $this->assertSame( 2, $summary['operations'], 'Unnamed operations are not counted as distinct operations.' ); + } + + public function test_time_series_fills_empty_buckets_and_groups_by_bucket(): void { + $this->insert_row( '2026-01-01 00:00:00' ); + $this->insert_row( '2026-01-01 00:59:59', 400, 'ERROR' ); + $this->insert_row( '2026-01-01 05:30:00' ); + $this->insert_row( '2026-01-01 23:59:59', 500, 'CRITICAL' ); + // Outside the window. + $this->insert_row( '2026-01-02 00:00:00' ); + + $series = $this->stats->get_time_series( $this->start, HOUR_IN_SECONDS, 24 ); + + $this->assertCount( 24, $series ); + $this->assertSame( [ 'total' => 2, 'errors' => 1 ], $series[0] ); + $this->assertSame( [ 'total' => 0, 'errors' => 0 ], $series[1] ); + $this->assertSame( [ 'total' => 1, 'errors' => 0 ], $series[5] ); + $this->assertSame( [ 'total' => 1, 'errors' => 1 ], $series[23] ); + $this->assertSame( 4, array_sum( array_column( $series, 'total' ) ) ); + } + + public function test_time_series_daily_buckets(): void { + $this->insert_row( '2026-01-01 12:00:00' ); + $this->insert_row( '2026-01-03 00:00:00' ); + $this->insert_row( '2026-01-03 23:00:00', 400, 'ERROR' ); + + $series = $this->stats->get_time_series( $this->start, DAY_IN_SECONDS, 7 ); + + $this->assertCount( 7, $series ); + $this->assertSame( 1, $series[0]['total'] ); + $this->assertSame( 0, $series[1]['total'] ); + $this->assertSame( [ 'total' => 2, 'errors' => 1 ], $series[2] ); + } + + public function test_level_breakdown_orders_by_level_descending(): void { + $this->insert_row( '2026-01-01 01:00:00' ); + $this->insert_row( '2026-01-01 02:00:00' ); + $this->insert_row( '2026-01-01 03:00:00', 400, 'ERROR' ); + + $levels = $this->stats->get_level_breakdown( $this->start, $this->end( 24 ) ); + + $this->assertSame( + [ + [ 'level' => 400, 'level_name' => 'ERROR', 'total' => 1 ], + [ 'level' => 200, 'level_name' => 'INFO', 'total' => 2 ], + ], + $levels + ); + } + + public function test_top_operations_groups_unnamed_and_respects_limit(): void { + $this->insert_row( '2026-01-01 01:00:00', 200, 'INFO', 'GetPosts' ); + $this->insert_row( '2026-01-01 02:00:00', 400, 'ERROR', 'GetPosts' ); + $this->insert_row( '2026-01-01 03:00:00', 200, 'INFO', 'GetPosts' ); + $this->insert_row( '2026-01-01 04:00:00', 200, 'INFO', null ); + $this->insert_row( '2026-01-01 05:00:00', 200, 'INFO', null ); + $this->insert_row( '2026-01-01 06:00:00', 200, 'INFO', 'GetMenu' ); + + $operations = $this->stats->get_top_operations( $this->start, $this->end( 24 ), 2 ); + + $this->assertSame( + [ + [ 'operation' => 'GetPosts', 'total' => 3, 'errors' => 1 ], + [ 'operation' => '', 'total' => 2, 'errors' => 0 ], + ], + $operations + ); + } + + public function test_top_operations_treats_missing_operation_key_as_unnamed(): void { + global $wpdb; + $wpdb->insert( + WordPressDatabaseEntity::get_table_name(), + [ + 'channel' => 'wpgraphql_logging', + 'level' => 200, + 'level_name' => 'INFO', + 'message' => 'WPGraphQL Pre Request', + 'context' => wp_json_encode( [ 'query' => '{ posts { nodes { id } } }' ] ), + 'extra' => wp_json_encode( [] ), + 'datetime' => '2026-01-01 01:00:00', + ] + ); + + $operations = $this->stats->get_top_operations( $this->start, $this->end( 24 ) ); + + $this->assertSame( [ [ 'operation' => '', 'total' => 1, 'errors' => 0 ] ], $operations ); + } +} From 4f287adaa94579b531707bc8a1c7eee8714a88e1 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:38:25 -0400 Subject: [PATCH 2/4] fix(logging): follow local calendar days and user locale on the dashboard Daily buckets were fixed 24-hour blocks counted from local midnight, so after a daylight saving change the last hour of the day could fall outside the window and labels could slip a day. Daily ranges now use local calendar-day boundaries built from hourly SQL counts. Labels are no longer cached, so each user sees dates in their own locale. Also drops the unvalidated dashboard data filter, which could break the template, and truncates long operation names in the table (full name in the title attribute). --- .../src/Admin/DashboardPage.php | 209 ++++++++++++------ .../Templates/WPGraphQLLoggerDashboard.php | 9 +- 2 files changed, 150 insertions(+), 68 deletions(-) diff --git a/plugins/wpgraphql-logging/src/Admin/DashboardPage.php b/plugins/wpgraphql-logging/src/Admin/DashboardPage.php index ad01083c9..a561f3101 100644 --- a/plugins/wpgraphql-logging/src/Admin/DashboardPage.php +++ b/plugins/wpgraphql-logging/src/Admin/DashboardPage.php @@ -5,7 +5,6 @@ namespace WPGraphQL\Logging\Admin; use DateTimeImmutable; -use DateTimeZone; use WPGraphQL\Logging\Admin\Dashboard\TimeSeriesChart; use WPGraphQL\Logging\Logger\Api\LogStatsInterface; use WPGraphQL\Logging\Logger\Database\WordPressDatabaseLogService; @@ -227,31 +226,51 @@ public function get_current_range(): string { } /** - * Computes the UTC start of the window for a range. + * Computes the window and bucket boundaries for a range, aligned to the site time zone. * - * Buckets are aligned to the site time zone: hourly ranges start at the top of the hour and - * daily ranges start at local midnight, so the last bucket always contains "now". + * Hourly ranges start at the top of a local hour. Daily ranges use local calendar days, + * so a day can be 23 or 25 hours long when daylight saving time changes. The last bucket + * always contains "now". * - * @param int $bucket_seconds Bucket size in seconds. - * @param int $bucket_count Number of buckets. - * @param \DateTimeImmutable|null $now The current time (for testing). + * @param array{label: string, bucket_seconds: int, bucket_count: int} $range The range definition. + * @param \DateTimeImmutable|null $now The current time (for testing). + * + * @return array{start: int, end: int, bucket_starts: array} Unix timestamps. */ - public function get_window_start( int $bucket_seconds, int $bucket_count, ?DateTimeImmutable $now = null ): DateTimeImmutable { - $now = ( $now ?? new DateTimeImmutable( 'now' ) )->setTimezone( wp_timezone() ); + public function get_window( array $range, ?DateTimeImmutable $now = null ): array { + $now = ( $now ?? new DateTimeImmutable( 'now' ) )->setTimezone( wp_timezone() ); + $bucket_seconds = max( 1, $range['bucket_seconds'] ); + $bucket_count = max( 1, $range['bucket_count'] ); + $bucket_starts = []; if ( DAY_IN_SECONDS === $bucket_seconds ) { - // Step back in calendar days so the window starts at local midnight even across DST changes. - $start = $now->setTime( 0, 0 )->modify( sprintf( '-%d days', $bucket_count - 1 ) )->getTimestamp(); - return ( new DateTimeImmutable( '@' . $start ) )->setTimezone( new DateTimeZone( 'UTC' ) ); + $day = $now->setTime( 0, 0 )->modify( sprintf( '-%d days', $bucket_count - 1 ) ); + for ( $i = 0; $i < $bucket_count; $i++ ) { + $bucket_starts[] = $day->getTimestamp(); + $day = $day->modify( '+1 day' ); + } + + return [ + 'start' => $bucket_starts[0], + 'end' => $day->getTimestamp(), + 'bucket_starts' => $bucket_starts, + ]; } - $bucket_seconds = max( 1, $bucket_seconds ); - $aligned = HOUR_IN_SECONDS === $bucket_seconds + $aligned = HOUR_IN_SECONDS === $bucket_seconds ? $now->setTime( (int) $now->format( 'G' ), 0 )->getTimestamp() : $now->getTimestamp() - ( $now->getTimestamp() % $bucket_seconds ); - $start = $aligned - ( $bucket_seconds * ( $bucket_count - 1 ) ); + $start = $aligned - ( $bucket_seconds * ( $bucket_count - 1 ) ); - return ( new DateTimeImmutable( '@' . $start ) )->setTimezone( new DateTimeZone( 'UTC' ) ); + for ( $i = 0; $i < $bucket_count; $i++ ) { + $bucket_starts[] = $start + ( $i * $bucket_seconds ); + } + + return [ + 'start' => $start, + 'end' => $start + ( $bucket_seconds * $bucket_count ), + 'bucket_starts' => $bucket_starts, + ]; } /** @@ -277,11 +296,14 @@ public function get_stats_service(): ?LogStatsInterface { } /** - * Gets the dashboard data for a range, using a short-lived transient cache. + * Gets the aggregated dashboard data for a range, using a short-lived transient cache. + * + * Only raw numbers and timestamps are cached; labels are formatted per request so each + * user sees dates in their own locale. * * @param string $range The range slug. * - * @return array{range: string, start: int, end: int, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array}|null + * @return array{range: string, start: int, end: int, bucket_starts: array, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array}|null */ public function get_dashboard_data( string $range ): ?array { $ranges = $this->get_ranges(); @@ -297,33 +319,25 @@ public function get_dashboard_data( string $range ): ?array { $cache_key = self::CACHE_KEY_PREFIX . $range; $cached = get_transient( $cache_key ); if ( is_array( $cached ) && isset( $cached['range'] ) && $range === $cached['range'] ) { - /** @var array{range: string, start: int, end: int, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array} $cached */ + /** @var array{range: string, start: int, end: int, bucket_starts: array, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array} $cached */ return $cached; } - $bucket_seconds = $ranges[ $range ]['bucket_seconds']; - $bucket_count = $ranges[ $range ]['bucket_count']; - $start = $this->get_window_start( $bucket_seconds, $bucket_count ); - $end = $start->setTimestamp( $start->getTimestamp() + ( $bucket_seconds * $bucket_count ) ); + $window = $this->get_window( $ranges[ $range ] ); + $start = new DateTimeImmutable( '@' . $window['start'] ); + $end = new DateTimeImmutable( '@' . $window['end'] ); $data = [ - 'range' => $range, - 'start' => $start->getTimestamp(), - 'end' => $end->getTimestamp(), - 'summary' => $stats->get_summary( $start, $end ), - 'series' => $this->label_series( $stats->get_time_series( $start, $bucket_seconds, $bucket_count ), $start->getTimestamp(), $bucket_seconds ), - 'levels' => $stats->get_level_breakdown( $start, $end ), - 'operations' => $stats->get_top_operations( $start, $end, self::TOP_OPERATIONS_LIMIT ), + 'range' => $range, + 'start' => $window['start'], + 'end' => $window['end'], + 'bucket_starts' => $window['bucket_starts'], + 'summary' => $stats->get_summary( $start, $end ), + 'series' => $this->get_series( $stats, $window, $ranges[ $range ]['bucket_seconds'] ), + 'levels' => $stats->get_level_breakdown( $start, $end ), + 'operations' => $stats->get_top_operations( $start, $end, self::TOP_OPERATIONS_LIMIT ), ]; - /** - * Filters the dashboard data before it is cached and rendered. - * - * @param array $data The dashboard data. - * @param string $range The range slug. - */ - $data = apply_filters( 'wpgraphql_logging_dashboard_data', $data, $range ); - /** * Filters how long dashboard data is cached, in seconds. Return 0 to disable caching. * @@ -348,7 +362,8 @@ public function render_page(): void { $ranges = $this->get_ranges(); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable $current_range = $this->get_current_range(); - $data = $this->get_dashboard_data( $current_range ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $data = $this->get_dashboard_data( $current_range ); + $series = null === $data ? [] : $this->label_series( $data['series'], $data['bucket_starts'], $ranges[ $current_range ]['bucket_seconds'] ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable $chart = new TimeSeriesChart(); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable $base_url = menu_page_url( self::ADMIN_PAGE_SLUG, false ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable $logs_url = menu_page_url( ViewLogsPage::ADMIN_PAGE_SLUG, false ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable @@ -361,6 +376,39 @@ public function render_page(): void { require $template; // @phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable } + /** + * Adds display labels to the bucket counts. + * + * @param array $series Bucket counts. + * @param array $bucket_starts Bucket start timestamps. + * @param int $bucket_seconds The nominal bucket size. + * + * @return array + */ + public function label_series( array $series, array $bucket_starts, int $bucket_seconds ): array { + $daily = $bucket_seconds >= DAY_IN_SECONDS; + $date_format = (string) get_option( 'date_format', 'F j, Y' ); + $time_format = (string) get_option( 'time_format', 'g:i a' ); + /* translators: Short date format for chart axis labels, see https://www.php.net/manual/datetime.format.php */ + $short_date = __( 'M j', 'wpgraphql-logging' ); + $labelled = []; + + foreach ( array_values( $bucket_starts ) as $index => $timestamp ) { + $counts = $series[ $index ] ?? [ + 'total' => 0, + 'errors' => 0, + ]; + $labelled[] = [ + 'label' => (string) wp_date( $daily ? $short_date : $time_format, $timestamp ), + 'full_label' => (string) wp_date( $daily ? $date_format : $date_format . ' ' . $time_format, $timestamp ), + 'total' => $counts['total'], + 'errors' => $counts['errors'], + ]; + } + + return $labelled; + } + /** * Returns a part of a whole as a percentage, or 0 when the whole is empty. * @@ -388,6 +436,47 @@ public static function get_level_modifier( int $level ): string { return $level >= self::WARNING_LEVEL ? 'warnings' : 'entries'; } + /** + * Sums fixed-size source buckets into (possibly uneven) target buckets. + * + * @param array $source Source counts keyed by index. + * @param int $source_start Start of source bucket 0 (Unix timestamp). + * @param int $source_seconds Size of each source bucket in seconds. + * @param array $bucket_starts Ascending start timestamps of the target buckets. + * + * @return array Target counts, one per bucket start. + */ + public static function fold_series( array $source, int $source_start, int $source_seconds, array $bucket_starts ): array { + $bucket_starts = array_values( $bucket_starts ); + $count = count( $bucket_starts ); + if ( 0 === $count ) { + return []; + } + + $totals = array_fill( 0, $count, 0 ); + $errors = array_fill( 0, $count, 0 ); + + ksort( $source ); + $bucket = 0; + foreach ( $source as $index => $counts ) { + $timestamp = $source_start + ( $index * $source_seconds ); + while ( $bucket + 1 < $count && $timestamp >= $bucket_starts[ $bucket + 1 ] ) { + ++$bucket; + } + $totals[ $bucket ] += $counts['total']; + $errors[ $bucket ] += $counts['errors']; + } + + return array_map( + static fn ( int $total, int $error_count ): array => [ + 'total' => $total, + 'errors' => $error_count, + ], + $totals, + $errors + ); + } + /** * Checks that a filtered range has a key-safe slug, a label and positive integer bucket settings. * @@ -407,35 +496,27 @@ protected function is_valid_range( string $slug, $range ): bool { } /** - * Adds display labels to time series buckets. + * Gets the counts for each bucket in a window. * - * @param array $series Counts keyed by bucket index. - * @param int $start Window start as a Unix timestamp. - * @param int $bucket_seconds Bucket size in seconds. + * Daily buckets follow local calendar days, which are not always 24 hours long, so they + * are built from hourly counts rather than fixed 24-hour SQL buckets. * - * @return array + * @param \WPGraphQL\Logging\Logger\Api\LogStatsInterface $stats The stats service. + * @param array{start: int, end: int, bucket_starts: array} $window The window. + * @param int $bucket_seconds The nominal bucket size. + * + * @return array */ - protected function label_series( array $series, int $start, int $bucket_seconds ): array { - $daily = $bucket_seconds >= DAY_IN_SECONDS; - $date_format = (string) get_option( 'date_format', 'F j, Y' ); - $time_format = (string) get_option( 'time_format', 'g:i a' ); - $labelled = []; - - ksort( $series ); - foreach ( $series as $index => $counts ) { - $timestamp = $start + ( $index * $bucket_seconds ); - if ( $daily ) { - // Label daily buckets by their midpoint so a DST shift cannot push the label onto the previous day. - $timestamp += intdiv( $bucket_seconds, 2 ); - } - $labelled[] = [ - 'label' => (string) wp_date( $daily ? 'M j' : $time_format, $timestamp ), - 'full_label' => (string) wp_date( $daily ? $date_format : $date_format . ' ' . $time_format, $timestamp ), - 'total' => (int) $counts['total'], - 'errors' => (int) $counts['errors'], - ]; + protected function get_series( LogStatsInterface $stats, array $window, int $bucket_seconds ): array { + $start = new DateTimeImmutable( '@' . $window['start'] ); + + if ( DAY_IN_SECONDS !== $bucket_seconds ) { + return array_values( $stats->get_time_series( $start, $bucket_seconds, count( $window['bucket_starts'] ) ) ); } - return $labelled; + $hours = (int) ceil( ( $window['end'] - $window['start'] ) / HOUR_IN_SECONDS ); + $hourly = $stats->get_time_series( $start, HOUR_IN_SECONDS, max( 1, $hours ) ); + + return self::fold_series( $hourly, $window['start'], HOUR_IN_SECONDS, $window['bucket_starts'] ); } } diff --git a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php index 475dc04b7..69a5735d7 100644 --- a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php +++ b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php @@ -11,7 +11,8 @@ * * @var array $ranges * @var string $current_range - * @var array{range: string, start: int, end: int, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array}|null $data + * @var array{range: string, start: int, end: int, bucket_starts: array, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array}|null $data + * @var array $series * @var \WPGraphQL\Logging\Admin\Dashboard\TimeSeriesChart $chart * @var string $base_url * @var string $logs_url @@ -100,7 +101,7 @@ class=""
render( - $data['series'], + $series, 'wpgraphql-logging-entries-chart', /* translators: %s: time range label, e.g. "Last 7 days". */ sprintf( __( 'Log entries over time, %s', 'wpgraphql-logging' ), $wpgraphql_logging_range_label ), @@ -125,7 +126,7 @@ class="" - + @@ -200,7 +201,7 @@ class="" - + From 5af913c97bec001870e41b736420da67a762978c Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:41:33 -0400 Subject: [PATCH 3/4] test(logging): cover the dashboard page and SVG chart Add wpunit tests for DashboardPage (capability check, menu registration, range validation, DST-aware windows, folding hourly counts into days, caching, custom stats services, escaping of operation names) and for TimeSeriesChart (scale, well-formed accessible SVG, escaping, empty data, label thinning). The e2e spec now sends operationName, because the dashboard groups operations by the name the client sends. --- .../tests/e2e/specs/dashboard.spec.js | 7 +- .../Admin/Dashboard/TimeSeriesChartTest.php | 108 ++++++ .../tests/wpunit/Admin/DashboardPageTest.php | 361 ++++++++++++++++++ 3 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 plugins/wpgraphql-logging/tests/wpunit/Admin/Dashboard/TimeSeriesChartTest.php create mode 100644 plugins/wpgraphql-logging/tests/wpunit/Admin/DashboardPageTest.php diff --git a/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js b/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js index c462fc872..dd1d724db 100644 --- a/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js +++ b/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js @@ -3,7 +3,6 @@ import { goToLoggingSettingsPage, goToDashboardPage, configureLogging, - executeGraphQLQuery, resetPluginSettings, } from "../utils"; import { GET_POSTS_QUERY } from "../constants"; @@ -56,7 +55,11 @@ test.describe("Logging Dashboard", () => { eventLogSelection: ["graphql_request_results"], }); - const response = await executeGraphQLQuery(request, GET_POSTS_QUERY); + // The dashboard groups by the operationName the client sends, so include it. + const response = await request.post("/graphql", { + data: { query: GET_POSTS_QUERY, operationName: "GetPosts" }, + headers: { "Content-Type": "application/json" }, + }); expect(response.ok()).toBeTruthy(); // The reset helper clears cached dashboard figures, so the new entry shows up straight away. diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/Dashboard/TimeSeriesChartTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/Dashboard/TimeSeriesChartTest.php new file mode 100644 index 000000000..8d5ef5580 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/Dashboard/TimeSeriesChartTest.php @@ -0,0 +1,108 @@ + $points + */ + private function render( array $points, string $title = 'Entries', string $description = 'Description' ): string { + ob_start(); + ( new TimeSeriesChart() )->render( $points, 'test-chart', $title, $description ); + return (string) ob_get_clean(); + } + + private function point( string $label, int $total, int $errors = 0 ): array { + return [ + 'label' => $label, + 'full_label' => 'Full ' . $label, + 'total' => $total, + 'errors' => $errors, + ]; + } + + public function test_nice_scale(): void { + $this->assertSame( [ 4, 1 ], TimeSeriesChart::nice_scale( 0 ) ); + $this->assertSame( [ 1, 1 ], TimeSeriesChart::nice_scale( 1 ) ); + $this->assertSame( [ 8, 2 ], TimeSeriesChart::nice_scale( 7 ) ); + $this->assertSame( [ 15, 5 ], TimeSeriesChart::nice_scale( 13 ) ); + $this->assertSame( [ 40, 10 ], TimeSeriesChart::nice_scale( 37 ) ); + $this->assertSame( [ 1000, 500 ], TimeSeriesChart::nice_scale( 999 ) ); + } + + public function test_render_outputs_accessible_well_formed_svg(): void { + $svg = $this->render( + [ + $this->point( 'Mon', 10, 2 ), + $this->point( 'Tue', 0 ), + $this->point( 'Wed', 3, 3 ), + ] + ); + + $document = new DOMDocument(); + $this->assertTrue( $document->loadXML( $svg ), 'Chart markup should be well-formed XML.' ); + + $this->assertStringContainsString( 'role="img"', $svg ); + $this->assertStringContainsString( 'aria-labelledby="test-chart-title test-chart-desc"', $svg ); + $this->assertStringContainsString( 'Entries', $svg ); + $this->assertStringContainsString( 'Description', $svg ); + + // One hover group per point, each with a native tooltip. + $this->assertSame( 3, substr_count( $svg, 'class="wpgraphql-logging-chart__slot"' ) ); + $this->assertStringContainsString( 'Full Mon: 10 entries, 2 errors', $svg ); + $this->assertStringContainsString( 'Full Tue: 0 entries, 0 errors', $svg ); + + // Mon has two segments, Tue none, Wed only an error segment. + $this->assertSame( 2, substr_count( $svg, 'wpgraphql-logging-chart__bar--errors' ) ); + $this->assertSame( 1, substr_count( $svg, 'wpgraphql-logging-chart__bar--entries' ) ); + } + + public function test_render_escapes_text(): void { + $svg = $this->render( [ $this->point( 'x', 1 ) ], 'A & B '; + $this->assertSame( DashboardPage::DEFAULT_RANGE, $page->get_current_range() ); + + $_GET['range'] = [ '30d' ]; + $this->assertSame( DashboardPage::DEFAULT_RANGE, $page->get_current_range() ); + } + + public function test_get_ranges_drops_malformed_filtered_ranges(): void { + add_filter( + 'wpgraphql_logging_dashboard_ranges', + static function ( array $ranges ): array { + $ranges['90d'] = [ + 'label' => 'Last 90 days', + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 90, + ]; + $ranges['broken'] = [ 'label' => 'Broken' ]; + $ranges['zero'] = [ + 'label' => 'Zero', + 'bucket_seconds' => 0, + 'bucket_count' => 10, + ]; + $ranges['Bad Slug'] = [ + 'label' => 'Bad', + 'bucket_seconds' => 60, + 'bucket_count' => 10, + ]; + return $ranges; + } + ); + + $ranges = ( new DashboardPage() )->get_ranges(); + + $this->assertSame( [ '24h', '7d', '30d', '90d' ], array_keys( $ranges ) ); + } + + public function test_get_ranges_falls_back_to_defaults_when_filter_returns_garbage(): void { + add_filter( 'wpgraphql_logging_dashboard_ranges', '__return_false' ); + $this->assertSame( [ '24h', '7d', '30d' ], array_keys( ( new DashboardPage() )->get_ranges() ) ); + } + + public function test_get_window_hourly_ends_after_now(): void { + $page = new DashboardPage(); + $now = new DateTimeImmutable( '2026-03-10 14:25:00', new DateTimeZone( 'UTC' ) ); + $window = $page->get_window( + [ + 'label' => '24h', + 'bucket_seconds' => HOUR_IN_SECONDS, + 'bucket_count' => 24, + ], + $now + ); + + $this->assertCount( 24, $window['bucket_starts'] ); + $this->assertSame( '2026-03-09 15:00:00', gmdate( 'Y-m-d H:i:s', $window['start'] ) ); + $this->assertSame( '2026-03-10 15:00:00', gmdate( 'Y-m-d H:i:s', $window['end'] ) ); + $this->assertSame( '2026-03-10 14:00:00', gmdate( 'Y-m-d H:i:s', end( $window['bucket_starts'] ) ) ); + } + + public function test_get_window_daily_uses_local_calendar_days_across_dst(): void { + update_option( 'timezone_string', 'America/New_York' ); + $page = new DashboardPage(); + + // Daylight saving time ends in New York at 02:00 on 2026-11-01, so that day has 25 hours. + $now = new DateTimeImmutable( '2026-11-01 23:30:00', new DateTimeZone( 'America/New_York' ) ); + $window = $page->get_window( + [ + 'label' => '7d', + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 7, + ], + $now + ); + + $local = static fn ( int $timestamp ): string => wp_date( 'Y-m-d H:i', $timestamp ); + + $this->assertSame( '2026-10-26 00:00', $local( $window['start'] ) ); + $this->assertSame( '2026-11-02 00:00', $local( $window['end'] ) ); + $this->assertSame( '2026-11-01 00:00', $local( end( $window['bucket_starts'] ) ) ); + $this->assertSame( 169 * HOUR_IN_SECONDS, $window['end'] - $window['start'] ); + $this->assertGreaterThanOrEqual( end( $window['bucket_starts'] ), $now->getTimestamp() ); + $this->assertLessThan( $window['end'], $now->getTimestamp() ); + } + + public function test_fold_series_sums_into_uneven_buckets(): void { + $start = 1000; + $source = [ + 0 => [ 'total' => 1, 'errors' => 0 ], + 1 => [ 'total' => 2, 'errors' => 1 ], + 2 => [ 'total' => 3, 'errors' => 0 ], + 4 => [ 'total' => 4, 'errors' => 2 ], + ]; + + // Buckets of 1, 3 and 1 source units. + $folded = DashboardPage::fold_series( $source, $start, 10, [ 1000, 1010, 1040 ] ); + + $this->assertSame( + [ + [ 'total' => 1, 'errors' => 0 ], + [ 'total' => 5, 'errors' => 1 ], + [ 'total' => 4, 'errors' => 2 ], + ], + $folded + ); + $this->assertSame( [], DashboardPage::fold_series( $source, $start, 10, [] ) ); + } + + public function test_percent_and_level_modifier(): void { + $this->assertSame( 0.0, DashboardPage::percent( 5, 0 ) ); + $this->assertSame( 25.0, DashboardPage::percent( 1, 4 ) ); + $this->assertSame( 'errors', DashboardPage::get_level_modifier( 400 ) ); + $this->assertSame( 'errors', DashboardPage::get_level_modifier( 600 ) ); + $this->assertSame( 'warnings', DashboardPage::get_level_modifier( 300 ) ); + $this->assertSame( 'entries', DashboardPage::get_level_modifier( 250 ) ); + } + + public function test_get_stats_service_defaults_to_database_and_is_filterable(): void { + $page = new DashboardPage(); + $this->assertInstanceOf( WordPressDatabaseLogStats::class, $page->get_stats_service() ); + + add_filter( 'wpgraphql_logging_dashboard_stats_service', '__return_null' ); + $this->assertNull( $page->get_stats_service() ); + $this->assertNull( $page->get_dashboard_data( '7d' ) ); + } + + public function test_get_dashboard_data_returns_null_for_unknown_range(): void { + $this->assertNull( ( new DashboardPage() )->get_dashboard_data( 'nope' ) ); + } + + public function test_get_dashboard_data_aggregates_and_caches(): void { + $now = time(); + $this->insert_row( $now - 60 ); + $this->insert_row( $now - 120, 400, 'ERROR', 'GetPage' ); + $this->insert_row( $now - ( 40 * DAY_IN_SECONDS ) ); // Outside every range. + + $page = new DashboardPage(); + $data = $page->get_dashboard_data( '7d' ); + + $this->assertIsArray( $data ); + $this->assertSame( [ 'total' => 2, 'errors' => 1, 'operations' => 2 ], $data['summary'] ); + $this->assertCount( 7, $data['series'] ); + $this->assertCount( 7, $data['bucket_starts'] ); + $this->assertSame( 2, array_sum( array_column( $data['series'], 'total' ) ) ); + $this->assertNotFalse( get_transient( DashboardPage::CACHE_KEY_PREFIX . '7d' ) ); + + // A new row is not visible until the cache expires. + $this->insert_row( $now - 30 ); + $this->assertSame( 2, $page->get_dashboard_data( '7d' )['summary']['total'] ); + + delete_transient( DashboardPage::CACHE_KEY_PREFIX . '7d' ); + $this->assertSame( 3, $page->get_dashboard_data( '7d' )['summary']['total'] ); + } + + public function test_cache_can_be_disabled(): void { + add_filter( 'wpgraphql_logging_dashboard_cache_ttl', '__return_zero' ); + ( new DashboardPage() )->get_dashboard_data( '24h' ); + $this->assertFalse( get_transient( DashboardPage::CACHE_KEY_PREFIX . '24h' ) ); + } + + public function test_custom_stats_service_is_used(): void { + $stub = new class() implements LogStatsInterface { + public function get_summary( DateTimeInterface $start, DateTimeInterface $end ): array { + return [ 'total' => 42, 'errors' => 0, 'operations' => 1 ]; + } + + public function get_time_series( DateTimeInterface $start, int $bucket_seconds, int $bucket_count ): array { + return array_fill( 0, $bucket_count, [ 'total' => 0, 'errors' => 0 ] ); + } + + public function get_level_breakdown( DateTimeInterface $start, DateTimeInterface $end ): array { + return []; + } + + public function get_top_operations( DateTimeInterface $start, DateTimeInterface $end, int $limit = 10 ): array { + return []; + } + }; + add_filter( 'wpgraphql_logging_dashboard_stats_service', static fn () => $stub ); + + $data = ( new DashboardPage() )->get_dashboard_data( '30d' ); + $this->assertSame( 42, $data['summary']['total'] ); + $this->assertCount( 30, $data['series'] ); + } + + public function test_label_series_formats_hourly_and_daily_labels(): void { + update_option( 'date_format', 'Y-m-d' ); + update_option( 'time_format', 'H:i' ); + $page = new DashboardPage(); + $start = gmmktime( 0, 0, 0, 1, 2, 2026 ); + + $hourly = $page->label_series( [ [ 'total' => 3, 'errors' => 1 ] ], [ $start ], HOUR_IN_SECONDS ); + $this->assertSame( '00:00', $hourly[0]['label'] ); + $this->assertSame( '2026-01-02 00:00', $hourly[0]['full_label'] ); + $this->assertSame( 3, $hourly[0]['total'] ); + + // Missing counts are treated as zero. + $daily = $page->label_series( [], [ $start ], DAY_IN_SECONDS ); + $this->assertSame( 'Jan 2', $daily[0]['label'] ); + $this->assertSame( '2026-01-02', $daily[0]['full_label'] ); + $this->assertSame( 0, $daily[0]['total'] ); + } + + public function test_render_page_outputs_dashboard_with_escaped_operation_names(): void { + $this->set_as_admin(); + $this->insert_row( time() - 60, 200, 'INFO', '' ); + + ob_start(); + ( new DashboardPage() )->render_page(); + $html = (string) ob_get_clean(); + + $this->assertStringContainsString( 'WPGraphQL Logging Dashboard', $html ); + $this->assertStringContainsString( 'role="img"', $html ); + $this->assertStringContainsString( 'Show data table', $html ); + $this->assertStringContainsString( 'aria-current="page"', $html ); + $this->assertStringNotContainsString( 'set_as_admin(); + add_filter( 'wpgraphql_logging_dashboard_stats_service', '__return_null' ); + + ob_start(); + ( new DashboardPage() )->render_page(); + $html = (string) ob_get_clean(); + + $this->assertStringContainsString( 'notice-warning', $html ); + $this->assertStringNotContainsString( 'role="img"', $html ); + } +} From edb9e63a1818343381a8fa7a500c5d1ff3a6a8d7 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:44:42 -0400 Subject: [PATCH 4/4] refactor(logging): simplify the dashboard template and document it Render the summary tiles from one list and the table bars from one helper. Draw each table bar inside a full-width track so bar lengths stay proportional; previously a max-width cap made large values look the same. Shorten long operation names without stripping characters, so names containing markup are shown escaped rather than blank. Document the dashboard, its filters and how to support it from a custom log store. --- .../how-to/update-log-store-service/index.md | 34 ++++++++ docs/plugins/wpgraphql-logging/index.md | 36 +++++++- .../wp-graphql-logging-dashboard.css | 15 ++-- plugins/wpgraphql-logging/readme.txt | 1 + .../src/Admin/DashboardPage.php | 14 ++++ .../Templates/WPGraphQLLoggerDashboard.php | 82 ++++++++----------- .../tests/wpunit/Admin/DashboardPageTest.php | 7 ++ 7 files changed, 136 insertions(+), 53 deletions(-) diff --git a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md index 20128e19d..db66e1b04 100644 --- a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md +++ b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md @@ -104,6 +104,40 @@ add_action( 'plugins_loaded', function() { With this in place, all logs from WPGraphQL Logging will be routed through your `MyFileLogService` and saved to a file instead of the database. +## Supporting the dashboard + +The admin dashboard reads its numbers through `\WPGraphQL\Logging\Logger\Api\LogStatsInterface`. The built-in implementation queries the plugin's database table, so it is only used with the default log store. With a custom log store the dashboard shows a notice instead of charts. + +To enable the dashboard for your store, implement `LogStatsInterface` and return it from the `wpgraphql_logging_dashboard_stats_service` filter. All dates passed to the interface are in UTC, and each method should aggregate in your storage layer rather than loading every entry into PHP. + +```php + 0, 'errors' => 0, 'operations' => 0 ]; + } + + public function get_time_series( DateTimeInterface $start, int $bucket_seconds, int $bucket_count ): array { + return array_fill( 0, $bucket_count, [ 'total' => 0, 'errors' => 0 ] ); + } + + public function get_level_breakdown( DateTimeInterface $start, DateTimeInterface $end ): array { + return []; + } + + public function get_top_operations( DateTimeInterface $start, DateTimeInterface $end, int $limit = 10 ): array { + return []; + } +} + +add_filter( 'wpgraphql_logging_dashboard_stats_service', function( $stats ) { + return new MyLogStats(); +} ); +``` + ## Contributing We welcome and appreciate contributions from the community. If you'd like to help improve this documentation, please check out our [Contributing Guide](https://github.com/wpengine/hwptoolkit/blob/main/CONTRIBUTING.md) for more details on how to get started. diff --git a/docs/plugins/wpgraphql-logging/index.md b/docs/plugins/wpgraphql-logging/index.md index 0231ad998..ccb97f06d 100644 --- a/docs/plugins/wpgraphql-logging/index.md +++ b/docs/plugins/wpgraphql-logging/index.md @@ -38,7 +38,7 @@ The plugin provides comprehensive data management capabilities, allowing you to ### Admin UI -The plugin delivers a admin UI with some filtering of logs around dates or level and the ability to download logs in CSV format. +The plugin delivers a admin UI with some filtering of logs around dates or level and the ability to download logs in CSV format, plus a dashboard with charts. *** @@ -159,6 +159,40 @@ You can also filter logs with the following filters: You can delete selected logs or all logs using the bulk action controls. +## Dashboard + +GraphQL Logs -> Dashboard summarizes the logs for the last 24 hours, 7 days (the default) or 30 days: + +* Totals for log entries, errors (level `ERROR` and above), the error rate and the number of distinct operations. +* A column chart of log entries over time, with errors stacked on top. The 24 hour range uses hourly columns; the other ranges use one column per day in the site time zone. A data table with the same numbers sits under the chart. +* Log entries grouped by level. +* The ten operations with the most log entries, with their error counts. + +The counts are log entries, not requests. One GraphQL request can write an entry for each enabled log point, and only sampled requests are logged. Operations are grouped by the `operationName` the client sends; requests without one are listed as "(unnamed)". + +The numbers are calculated in SQL and cached in a transient for 60 seconds. Change the cache lifetime, or return `0` to turn caching off, with the `wpgraphql_logging_dashboard_cache_ttl` filter: + +```php +add_filter( 'wpgraphql_logging_dashboard_cache_ttl', function( int $ttl, string $range ) { + return 5 * MINUTE_IN_SECONDS; +}, 10, 2 ); +``` + +The available ranges can be changed with the `wpgraphql_logging_dashboard_ranges` filter. Each range needs a label, a bucket size in seconds and a bucket count: + +```php +add_filter( 'wpgraphql_logging_dashboard_ranges', function( array $ranges ) { + $ranges['90d'] = [ + 'label' => __( 'Last 90 days', 'my-plugin' ), + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 90, + ]; + return $ranges; +} ); +``` + +If you [replace the log store](how-to/update-log-store-service/index.md), the dashboard shows a notice until you provide a stats service for your store. See that guide for details. + ## Uninstallation By default, WPGraphQL Logging preserves all logged data when the plugin is deactivated to prevent accidental data loss. diff --git a/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css b/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css index dce1bdc84..d608369d6 100644 --- a/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css +++ b/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css @@ -159,20 +159,25 @@ } .wpgraphql-logging-dashboard__bars th[scope="row"] { - word-break: break-word; + width: 40%; + overflow-wrap: anywhere; } .wpgraphql-logging-dashboard__bar-cell { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) 4.5em; align-items: center; gap: 8px; } +.wpgraphql-logging-dashboard__bar-track { + display: block; +} + .wpgraphql-logging-dashboard__bar { - display: inline-block; - flex: 0 1 auto; + display: block; min-width: 2px; - max-width: calc(100% - 64px); + max-width: 100%; height: 12px; border-radius: 0 4px 4px 0; } diff --git a/plugins/wpgraphql-logging/readme.txt b/plugins/wpgraphql-logging/readme.txt index 6db70d004..65b543189 100644 --- a/plugins/wpgraphql-logging/readme.txt +++ b/plugins/wpgraphql-logging/readme.txt @@ -25,6 +25,7 @@ WPGraphQL Logging plugin provides observability and visibility into the GraphQL **Advanced Admin Interface** * View and filter all logged GraphQL requests in the WordPress admin +* Dashboard with charts of log entries over time, entries by level and the top operations * Search and sort by date, query name, log level, or custom criteria * Export logs to CSV for offline analysis * Configurable data retention policies with automatic cleanup diff --git a/plugins/wpgraphql-logging/src/Admin/DashboardPage.php b/plugins/wpgraphql-logging/src/Admin/DashboardPage.php index a561f3101..80ed6d133 100644 --- a/plugins/wpgraphql-logging/src/Admin/DashboardPage.php +++ b/plugins/wpgraphql-logging/src/Admin/DashboardPage.php @@ -423,6 +423,20 @@ public static function percent( int $part, int $whole ): float { return ( (float) $part / (float) $whole ) * 100.0; } + /** + * Shortens a label for display, keeping multibyte characters intact. + * + * @param string $text The text to shorten. + * @param int $max_length Maximum number of characters before an ellipsis is added. + */ + public static function truncate( string $text, int $max_length = 80 ): string { + if ( mb_strlen( $text ) <= $max_length ) { + return $text; + } + + return mb_substr( $text, 0, $max_length ) . "\u{2026}"; + } + /** * Maps a Monolog level to the CSS modifier used for its bar: errors, warnings or entries. * diff --git a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php index 69a5735d7..c0ad04e5f 100644 --- a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php +++ b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php @@ -59,31 +59,36 @@ class="" -
    -
  • - - -
  • -
  • - - -
  • -
  • - - - + $wpgraphql_logging_summary = $data['summary']; + $wpgraphql_logging_tiles = [ + __( 'Log entries', 'wpgraphql-logging' ) => number_format_i18n( $wpgraphql_logging_summary['total'] ), + __( 'Errors', 'wpgraphql-logging' ) => number_format_i18n( $wpgraphql_logging_summary['errors'] ), + /* translators: %s: error rate as a percentage. */ + __( 'Error rate', 'wpgraphql-logging' ) => sprintf( __( '%s%%', 'wpgraphql-logging' ), number_format_i18n( DashboardPage::percent( $wpgraphql_logging_summary['errors'], $wpgraphql_logging_summary['total'] ), 1 ) ), + __( 'Distinct operations', 'wpgraphql-logging' ) => number_format_i18n( $wpgraphql_logging_summary['operations'] ), + ]; + + // Outputs a table cell with a proportional bar and its value. + $wpgraphql_logging_bar_cell = static function ( int $value, int $max, string $modifier ): void { + ?> + + + + -
  • -
  • - - -
  • + + +
      + $wpgraphql_logging_tile_value ) : ?> +
    • + + +
    • +
    @@ -155,19 +160,10 @@ class="" - + - - - - - - + @@ -192,24 +188,16 @@ class="" - + - + - - - - - - + @@ -220,7 +208,7 @@ class=""

    - +

    diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/DashboardPageTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/DashboardPageTest.php index 4aea26a11..bea056770 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Admin/DashboardPageTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/DashboardPageTest.php @@ -246,6 +246,12 @@ public function test_percent_and_level_modifier(): void { $this->assertSame( 'entries', DashboardPage::get_level_modifier( 250 ) ); } + public function test_truncate_keeps_short_text_and_shortens_long_text(): void { + $this->assertSame( 'GetPosts', DashboardPage::truncate( 'GetPosts' ) ); + $this->assertSame( 'abc' . "\u{2026}", DashboardPage::truncate( 'abcdef', 3 ) ); + $this->assertSame( 'ééé' . "\u{2026}", DashboardPage::truncate( 'éééé', 3 ) ); + } + public function test_get_stats_service_defaults_to_database_and_is_filterable(): void { $page = new DashboardPage(); $this->assertInstanceOf( WordPressDatabaseLogStats::class, $page->get_stats_service() ); @@ -345,6 +351,7 @@ public function test_render_page_outputs_dashboard_with_escaped_operation_names( $this->assertStringContainsString( 'Show data table', $html ); $this->assertStringContainsString( 'aria-current="page"', $html ); $this->assertStringNotContainsString( 'assertStringContainsString( '<img src=x onerror=alert(1)>', $html ); } public function test_render_page_shows_notice_when_dashboard_unavailable(): void {