jobs->schedule_recurring( self::HEARTBEAT_HOOK, 5 * 60, 'bookline' ); } /** * Record a heartbeat timestamp. * * @since 0.1.0 */ public function beat(): void { $now = $this->clock->now()->setTimezone( new \DateTimeZone( 'UTC' ) )->format( DATE_ATOM ); if ( function_exists( 'update_option' ) ) { update_option( self::HEARTBEAT_OPTION, $now, false ); return; } $GLOBALS['bookline_heartbeat_at'] = $now; } /** * Last heartbeat instant, if any. * * @since 0.1.0 */ public function last_heartbeat(): ?DateTimeImmutable { $raw = function_exists( 'get_option' ) ? get_option( self::HEARTBEAT_OPTION, null ) : ( $GLOBALS['bookline_heartbeat_at'] ?? null ); if ( ! is_string( $raw ) || '' === $raw ) { return null; } $dt = DateTimeImmutable::createFromFormat( DATE_ATOM, $raw ); return false === $dt ? null : $dt; } /** * Whether the heartbeat is stale (>15 minutes). * * @since 0.1.0 */ public function is_stale(): bool { $last = $this->last_heartbeat(); if ( null === $last ) { return true; } $threshold = $this->clock->now()->sub( new DateInterval( 'PT' . self::STALE_AFTER_SECONDS . 'S' ) ); return $last < $threshold; } /** * Health payload for GET /admin/health. * * @return array * * @since 0.1.0 */ public function snapshot(): array { $last = $this->last_heartbeat(); $stale = $this->is_stale(); $scheduler_ok = $this->jobs->is_available(); $queue_depth = $this->jobs->pending_count( 'bookline' ); $failed_recent = $this->logs->count_by_status( NotificationStatus::Failed ); $outbound_live = OutboundGuard::allows_outbound(); $smtp_label = null !== $this->smtp ? $this->smtp->active_label() : null; $fix = null; if ( $stale ) { $fix = 'Bookline’s scheduler heartbeat is more than 15 minutes old. Ensure WP-Cron is running, or add `*/5 * * * * cd ' . ( defined( 'ABSPATH' ) ? ABSPATH : '/path/to/wordpress' ) . ' && wp cron event run --due-now` to system crontab. If DISABLE_WP_CRON is true, you must trigger cron externally.'; } elseif ( ! $outbound_live ) { $fix = 'Outbound mail is drafted, not sent. Release zips send by default. Add define( \'BOOKLINE_ALLOW_OUTBOUND\', false ); to wp-config.php only when you want draft-first, or define it as true if a previous install turned it off. Bookline uses wp_mail — install an SMTP plugin for reliable delivery.'; } return [ 'status' => $stale ? 'degraded' : 'ok', 'heartbeat' => null === $last ? null : $last->format( DATE_ATOM ), 'heartbeat_stale' => $stale, 'scheduler' => [ 'available' => $scheduler_ok, 'pending' => $queue_depth, 'failed' => $this->jobs->failed_count( 'bookline' ), ], 'notifications' => [ 'drafted' => $this->logs->count_by_status( NotificationStatus::Drafted ), 'queued' => $this->logs->count_by_status( NotificationStatus::Queued ), 'failed' => $failed_recent, 'sent' => $this->logs->count_by_status( NotificationStatus::Sent ), ], // Admin SPA aliases (docs/08 §2.2, §2.13). 'cron_healthy' => ! $stale && $scheduler_ok, 'db_healthy' => true, 'email_healthy' => true, 'outbound_live' => $outbound_live, 'smtp_plugin' => $smtp_label, 'queue_depth' => $queue_depth, 'failed_recent' => $failed_recent, 'fix_guidance' => $fix, ]; } }