Created
July 29, 2026 09:59
-
-
Save adamzero1/1cda9d5d6b2a810fdbf69fa660277b14 to your computer and use it in GitHub Desktop.
Magento 2 Cron Report
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| // Prevent varnish or browsers from caching this dynamic report. | |
| header('Content-Type: text/html; charset=UTF-8'); | |
| header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); | |
| header('Cache-Control: post-check=0, pre-check=0', false); | |
| header('Pragma: no-cache'); | |
| header('Expires: Thu, 01 Jan 1970 00:00:00 GMT'); | |
| header('Surrogate-Control: no-store'); | |
| $envPath = __DIR__ . '/app/etc/env.php'; | |
| if (!is_file($envPath)) { | |
| http_response_code(500); | |
| echo '<h1>Configuration Error</h1><p>Could not find app/etc/env.php.</p>'; | |
| exit; | |
| } | |
| $env = require $envPath; | |
| $dbConfig = $env['db']['connection']['default'] ?? null; | |
| if (!is_array($dbConfig)) { | |
| http_response_code(500); | |
| echo '<h1>Configuration Error</h1><p>Database connection config is missing in env.php.</p>'; | |
| exit; | |
| } | |
| $host = (string)($dbConfig['host'] ?? ''); | |
| $dbName = (string)($dbConfig['dbname'] ?? ''); | |
| $username = (string)($dbConfig['username'] ?? ''); | |
| $password = (string)($dbConfig['password'] ?? ''); | |
| $now = new DateTimeImmutable('now'); | |
| $defaultEnd = $now->format('Y-m-d H:i'); | |
| $defaultStart = $now->modify('-1 hour')->format('Y-m-d H:i'); | |
| $startInput = isset($_GET['start']) ? trim((string)$_GET['start']) : $defaultStart; | |
| $endInput = isset($_GET['end']) ? trim((string)$_GET['end']) : $defaultEnd; | |
| $errors = []; | |
| $start = DateTimeImmutable::createFromFormat('Y-m-d H:i', $startInput) ?: false; | |
| $end = DateTimeImmutable::createFromFormat('Y-m-d H:i', $endInput) ?: false; | |
| if (!$start) { | |
| $errors[] = 'Start time must be in format YYYY-MM-DD HH:MM.'; | |
| } | |
| if (!$end) { | |
| $errors[] = 'End time must be in format YYYY-MM-DD HH:MM.'; | |
| } | |
| if ($start && $end) { | |
| if ($end < $start) { | |
| $errors[] = 'End time must be after or equal to start time.'; | |
| } | |
| $minutesDiff = (int)(($end->getTimestamp() - $start->getTimestamp()) / 60); | |
| if ($minutesDiff > 24 * 60) { | |
| $errors[] = 'Time range cannot exceed 24 hours.'; | |
| } | |
| } | |
| $rowsByMinute = []; | |
| $statuses = []; | |
| $minuteBuckets = []; | |
| $jobRuntimeRows = []; | |
| if (empty($errors) && $start && $end) { | |
| try { | |
| $dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $host, $dbName); | |
| $pdo = new PDO( | |
| $dsn, | |
| $username, | |
| $password, | |
| [ | |
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |
| ] | |
| ); | |
| $sql = <<<'SQL' | |
| SELECT | |
| DATE_FORMAT(scheduled_at, '%Y-%m-%d %H:%i') AS minute_bucket, | |
| status, | |
| COUNT(*) AS job_count | |
| FROM cron_schedule | |
| WHERE scheduled_at >= :start_at | |
| AND scheduled_at <= :end_at | |
| GROUP BY minute_bucket, status | |
| ORDER BY minute_bucket ASC, status ASC | |
| SQL; | |
| $statement = $pdo->prepare($sql); | |
| $statement->execute([ | |
| ':start_at' => $start->format('Y-m-d H:i:00'), | |
| ':end_at' => $end->format('Y-m-d H:i:59'), | |
| ]); | |
| while ($row = $statement->fetch()) { | |
| $minute = (string)$row['minute_bucket']; | |
| $status = (string)$row['status']; | |
| $count = (int)$row['job_count']; | |
| $rowsByMinute[$minute][$status] = $count; | |
| $statuses[$status] = true; | |
| } | |
| $cursor = $start; | |
| while ($cursor <= $end) { | |
| $minuteBuckets[] = $cursor->format('Y-m-d H:i'); | |
| $cursor = $cursor->modify('+1 minute'); | |
| } | |
| } catch (Throwable $e) { | |
| $errors[] = 'Database error: ' . $e->getMessage(); | |
| } | |
| } | |
| if (empty($errors) && $start && $end) { | |
| try { | |
| $dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $host, $dbName); | |
| $pdo = new PDO( | |
| $dsn, | |
| $username, | |
| $password, | |
| [ | |
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |
| ] | |
| ); | |
| $runtimeSql = <<<'SQL' | |
| SELECT | |
| job_code, | |
| COUNT(*) AS runs_count, | |
| SUM( | |
| GREATEST( | |
| 1, | |
| TIMESTAMPDIFF( | |
| SECOND, | |
| executed_at, | |
| COALESCE(finished_at, DATE_ADD(executed_at, INTERVAL 1 HOUR)) | |
| ) | |
| ) | |
| ) AS total_runtime_seconds, | |
| AVG( | |
| GREATEST( | |
| 1, | |
| TIMESTAMPDIFF( | |
| SECOND, | |
| executed_at, | |
| COALESCE(finished_at, DATE_ADD(executed_at, INTERVAL 1 HOUR)) | |
| ) | |
| ) | |
| ) AS avg_runtime_seconds, | |
| MIN( | |
| GREATEST( | |
| 1, | |
| TIMESTAMPDIFF( | |
| SECOND, | |
| executed_at, | |
| COALESCE(finished_at, DATE_ADD(executed_at, INTERVAL 1 HOUR)) | |
| ) | |
| ) | |
| ) AS min_runtime_seconds, | |
| MAX( | |
| GREATEST( | |
| 1, | |
| TIMESTAMPDIFF( | |
| SECOND, | |
| executed_at, | |
| COALESCE(finished_at, DATE_ADD(executed_at, INTERVAL 1 HOUR)) | |
| ) | |
| ) | |
| ) AS max_runtime_seconds | |
| FROM cron_schedule | |
| WHERE executed_at IS NOT NULL | |
| AND executed_at >= :start_at | |
| AND executed_at <= :end_at | |
| GROUP BY job_code | |
| ORDER BY total_runtime_seconds DESC, job_code ASC | |
| SQL; | |
| $runtimeStatement = $pdo->prepare($runtimeSql); | |
| $runtimeStatement->execute([ | |
| ':start_at' => $start->format('Y-m-d H:i:00'), | |
| ':end_at' => $end->format('Y-m-d H:i:59'), | |
| ]); | |
| $jobRuntimeRows = $runtimeStatement->fetchAll() ?: []; | |
| } catch (Throwable $e) { | |
| $errors[] = 'Runtime query error: ' . $e->getMessage(); | |
| } | |
| } | |
| $statusColumns = array_keys($statuses); | |
| sort($statusColumns); | |
| $chartLabels = []; | |
| $chartDatasets = []; | |
| if (empty($errors) && !empty($statusColumns)) { | |
| $chartLabels = $minuteBuckets; | |
| $palette = [ | |
| '#1f77b4', | |
| '#2ca02c', | |
| '#ff7f0e', | |
| '#d62728', | |
| '#17becf', | |
| '#9467bd', | |
| '#8c564b', | |
| '#bcbd22', | |
| '#e377c2', | |
| '#7f7f7f', | |
| ]; | |
| foreach ($statusColumns as $index => $status) { | |
| $series = []; | |
| foreach ($minuteBuckets as $minute) { | |
| $series[] = (int)($rowsByMinute[$minute][$status] ?? 0); | |
| } | |
| $color = $palette[$index % count($palette)]; | |
| $chartDatasets[] = [ | |
| 'label' => $status, | |
| 'data' => $series, | |
| 'borderColor' => $color, | |
| 'backgroundColor' => hexToRgba($color, 0.35), | |
| 'fill' => true, | |
| 'pointRadius' => 0, | |
| 'borderWidth' => 1, | |
| 'tension' => 0.2, | |
| 'stack' => 'cron_status', | |
| ]; | |
| } | |
| } | |
| $runtimePieLabels = []; | |
| $runtimePieValues = []; | |
| if (!empty($jobRuntimeRows)) { | |
| foreach ($jobRuntimeRows as $jobRuntimeRow) { | |
| $runtimePieLabels[] = (string)$jobRuntimeRow['job_code']; | |
| $runtimePieValues[] = (int)$jobRuntimeRow['total_runtime_seconds']; | |
| } | |
| } | |
| function h(string $value): string | |
| { | |
| return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); | |
| } | |
| function hexToRgba(string $hex, float $alpha): string | |
| { | |
| $hex = ltrim($hex, '#'); | |
| if (strlen($hex) !== 6) { | |
| return 'rgba(0,0,0,' . $alpha . ')'; | |
| } | |
| $r = hexdec(substr($hex, 0, 2)); | |
| $g = hexdec(substr($hex, 2, 2)); | |
| $b = hexdec(substr($hex, 4, 2)); | |
| return sprintf('rgba(%d,%d,%d,%.2F)', $r, $g, $b, $alpha); | |
| } | |
| function secondsToReadable(int $seconds): string | |
| { | |
| $hours = intdiv($seconds, 3600); | |
| $minutes = intdiv($seconds % 3600, 60); | |
| $remainingSeconds = $seconds % 60; | |
| if ($hours > 0) { | |
| return sprintf('%dh %dm %ds', $hours, $minutes, $remainingSeconds); | |
| } | |
| if ($minutes > 0) { | |
| return sprintf('%dm %ds', $minutes, $remainingSeconds); | |
| } | |
| return sprintf('%ds', $remainingSeconds); | |
| } | |
| ?> | |
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Cron Schedule Report</title> | |
| <style> | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| margin: 20px; | |
| color: #111; | |
| background: #f6f7f9; | |
| } | |
| h1 { | |
| margin-bottom: 16px; | |
| } | |
| form { | |
| background: #fff; | |
| border: 1px solid #ddd; | |
| padding: 12px; | |
| border-radius: 8px; | |
| display: flex; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| align-items: end; | |
| margin-bottom: 16px; | |
| } | |
| label { | |
| display: flex; | |
| flex-direction: column; | |
| font-size: 14px; | |
| gap: 4px; | |
| } | |
| input[type="text"] { | |
| padding: 8px; | |
| border: 1px solid #bbb; | |
| border-radius: 6px; | |
| min-width: 220px; | |
| } | |
| button { | |
| padding: 9px 14px; | |
| border: 1px solid #333; | |
| border-radius: 6px; | |
| background: #222; | |
| color: #fff; | |
| cursor: pointer; | |
| } | |
| .notice { | |
| margin-bottom: 16px; | |
| padding: 10px; | |
| border-radius: 6px; | |
| background: #fff3cd; | |
| border: 1px solid #ffe08a; | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| background: #fff; | |
| } | |
| th, td { | |
| border: 1px solid #ddd; | |
| padding: 8px; | |
| text-align: right; | |
| } | |
| th:first-child, | |
| td:first-child { | |
| text-align: left; | |
| white-space: nowrap; | |
| } | |
| thead th { | |
| background: #f1f3f5; | |
| } | |
| .muted { | |
| color: #555; | |
| font-size: 13px; | |
| margin-bottom: 8px; | |
| } | |
| .chart-wrap { | |
| background: #fff; | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| padding: 10px; | |
| margin-bottom: 16px; | |
| } | |
| .chart-area { | |
| height: 360px; | |
| } | |
| .runtime-grid { | |
| display: grid; | |
| grid-template-columns: minmax(320px, 1fr) minmax(500px, 2fr); | |
| gap: 16px; | |
| margin-bottom: 16px; | |
| } | |
| @media (max-width: 1024px) { | |
| .runtime-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .pie-area { | |
| height: 360px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Cron Schedule Report</h1> | |
| <p class="muted">Grouped by <code>scheduled_at</code> minute and status.</p> | |
| <form method="get" action=""> | |
| <label> | |
| Start (YYYY-MM-DD HH:MM) | |
| <input type="text" name="start" value="<?= h($startInput) ?>" required> | |
| </label> | |
| <label> | |
| End (YYYY-MM-DD HH:MM) | |
| <input type="text" name="end" value="<?= h($endInput) ?>" required> | |
| </label> | |
| <button type="submit">Run report</button> | |
| </form> | |
| <?php if (!empty($errors)): ?> | |
| <div class="notice"> | |
| <?php foreach ($errors as $error): ?> | |
| <div><?= h($error) ?></div> | |
| <?php endforeach; ?> | |
| </div> | |
| <?php endif; ?> | |
| <?php if (empty($errors) && $start && $end): ?> | |
| <?php if (empty($statusColumns)): ?> | |
| <div class="notice">No cron records found for the selected range.</div> | |
| <?php else: ?> | |
| <div class="chart-wrap"> | |
| <div class="muted">Stacked line graph of job status counts per minute.</div> | |
| <div class="chart-area"> | |
| <canvas id="cronStatusChart" aria-label="Stacked cron status graph"></canvas> | |
| </div> | |
| </div> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Minute</th> | |
| <?php foreach ($statusColumns as $status): ?> | |
| <th><?= h($status) ?></th> | |
| <?php endforeach; ?> | |
| <th>Total</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <?php foreach ($minuteBuckets as $minute): ?> | |
| <?php | |
| $row = $rowsByMinute[$minute] ?? []; | |
| $total = 0; | |
| ?> | |
| <tr> | |
| <td><?= h($minute) ?></td> | |
| <?php foreach ($statusColumns as $status): ?> | |
| <?php | |
| $value = (int)($row[$status] ?? 0); | |
| $total += $value; | |
| ?> | |
| <td><?= $value ?></td> | |
| <?php endforeach; ?> | |
| <td><strong><?= $total ?></strong></td> | |
| </tr> | |
| <?php endforeach; ?> | |
| </tbody> | |
| </table> | |
| <?php endif; ?> | |
| <?php if (empty($jobRuntimeRows)): ?> | |
| <div class="notice">No job runtime records found for the selected range.</div> | |
| <?php else: ?> | |
| <div class="runtime-grid"> | |
| <div class="chart-wrap"> | |
| <div class="muted">Pie chart: share of total runtime by job.</div> | |
| <div class="pie-area"> | |
| <canvas id="runtimePieChart" aria-label="Job runtime pie chart"></canvas> | |
| </div> | |
| </div> | |
| <div> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Job</th> | |
| <th>Runs</th> | |
| <th>Total Runtime</th> | |
| <th>Average Runtime</th> | |
| <th>Min Runtime</th> | |
| <th>Max Runtime</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <?php foreach ($jobRuntimeRows as $runtimeRow): ?> | |
| <?php | |
| $jobCode = (string)$runtimeRow['job_code']; | |
| $runs = (int)$runtimeRow['runs_count']; | |
| $totalSeconds = (int)$runtimeRow['total_runtime_seconds']; | |
| $avgSeconds = (int)round((float)$runtimeRow['avg_runtime_seconds']); | |
| $minSeconds = (int)$runtimeRow['min_runtime_seconds']; | |
| $maxSeconds = (int)$runtimeRow['max_runtime_seconds']; | |
| ?> | |
| <tr> | |
| <td><?= h($jobCode) ?></td> | |
| <td><?= $runs ?></td> | |
| <td><?= h(secondsToReadable($totalSeconds)) ?></td> | |
| <td><?= h(secondsToReadable($avgSeconds)) ?></td> | |
| <td><?= h(secondsToReadable($minSeconds)) ?></td> | |
| <td><?= h(secondsToReadable($maxSeconds)) ?></td> | |
| </tr> | |
| <?php endforeach; ?> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <?php endif; ?> | |
| <?php endif; ?> | |
| <?php if (!empty($chartDatasets) || !empty($runtimePieValues)): ?> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script> | |
| <script> | |
| const labels = <?= json_encode($chartLabels, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; | |
| const datasets = <?= json_encode($chartDatasets, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; | |
| const runtimePieLabels = <?= json_encode($runtimePieLabels, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; | |
| const runtimePieValues = <?= json_encode($runtimePieValues, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; | |
| const chartElement = document.getElementById('cronStatusChart'); | |
| const runtimePieElement = document.getElementById('runtimePieChart'); | |
| const piePalette = [ | |
| '#1f77b4', '#2ca02c', '#ff7f0e', '#d62728', '#17becf', | |
| '#9467bd', '#8c564b', '#bcbd22', '#e377c2', '#7f7f7f' | |
| ]; | |
| if (chartElement && window.Chart) { | |
| new Chart(chartElement, { | |
| type: 'line', | |
| data: { | |
| labels, | |
| datasets | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| interaction: { | |
| mode: 'index', | |
| intersect: false | |
| }, | |
| plugins: { | |
| legend: { | |
| position: 'bottom' | |
| } | |
| }, | |
| scales: { | |
| x: { | |
| ticks: { | |
| maxTicksLimit: 20 | |
| } | |
| }, | |
| y: { | |
| stacked: true, | |
| beginAtZero: true, | |
| title: { | |
| display: true, | |
| text: 'Job Count' | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| if (runtimePieElement && window.Chart && runtimePieValues.length > 0) { | |
| const pieColors = runtimePieValues.map((_, index) => piePalette[index % piePalette.length]); | |
| new Chart(runtimePieElement, { | |
| type: 'pie', | |
| data: { | |
| labels: runtimePieLabels, | |
| datasets: [{ | |
| data: runtimePieValues, | |
| backgroundColor: pieColors, | |
| borderColor: '#fff', | |
| borderWidth: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| position: 'right' | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: (ctx) => { | |
| const value = Number(ctx.raw || 0); | |
| return `${ctx.label}: ${value}s`; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| </script> | |
| <?php endif; ?> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment