From 4c1d73791a23f05871e3fb1b9ddba820fe684b84 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 18 Mar 2026 05:36:39 +0100 Subject: [PATCH 1/2] Add heatmap analytics for job activity visualization - Add getHeatmapData() method to QueuedJobsTable for aggregating jobs by day/hour - Add heatmap action to QueuedJobsController with filters for metric, days, and job type - Add heatmapColor() helper method for color gradient - Create heatmap template with summary stats and interactive grid - Add navigation links between stats and heatmap views --- src/Controller/Admin/QueuedJobsController.php | 41 +++ src/Model/Table/QueuedJobsTable.php | 138 ++++++++ src/View/Helper/QueueHelper.php | 27 ++ templates/Admin/Queue/index.php | 11 +- templates/Admin/QueuedJobs/heatmap.php | 303 ++++++++++++++++++ templates/Admin/QueuedJobs/stats.php | 25 ++ 6 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 templates/Admin/QueuedJobs/heatmap.php diff --git a/src/Controller/Admin/QueuedJobsController.php b/src/Controller/Admin/QueuedJobsController.php index 73073ee1..717f461d 100644 --- a/src/Controller/Admin/QueuedJobsController.php +++ b/src/Controller/Admin/QueuedJobsController.php @@ -104,6 +104,47 @@ public function stats(): void { $this->set(compact('stats', 'jobTypes', 'jobType')); } + /** + * Heatmap method + * + * Shows a heatmap visualization of job activity by day of week and hour. + * + * @throws \Cake\Http\Exception\NotFoundException + * + * @return void + */ + public function heatmap(): void { + if (!Configure::read('Queue.isStatisticEnabled')) { + throw new NotFoundException('Not enabled'); + } + + $jobType = $this->request->getQuery('job_type'); + $metric = $this->request->getQuery('metric', 'created'); + $days = (int)$this->request->getQuery('days', 30); + + // Validate metric + if (!in_array($metric, ['created', 'completed'], true)) { + $metric = 'created'; + } + + // Validate days range + if ($days < 7) { + $days = 7; + } elseif ($days > 365) { + $days = 365; + } + + $heatmapData = $this->QueuedJobs->getHeatmapData($metric, $days, $jobType); + + $jobTypes = $this->QueuedJobs->find()->where()->find( + 'list', + keyField: 'job_task', + valueField: 'job_task', + )->distinct('job_task')->toArray(); + + $this->set(compact('heatmapData', 'jobTypes', 'jobType', 'metric', 'days')); + } + /** * View method * diff --git a/src/Model/Table/QueuedJobsTable.php b/src/Model/Table/QueuedJobsTable.php index f7b8bd39..da23d6ea 100644 --- a/src/Model/Table/QueuedJobsTable.php +++ b/src/Model/Table/QueuedJobsTable.php @@ -862,6 +862,144 @@ public function clone(QueuedJob $queuedJob): ?QueuedJob { return $this->save($queuedJob) ?: null; } + /** + * Returns heatmap data aggregated by day of week and hour. + * + * @param string $field Field to aggregate on ('created' or 'completed') + * @param int $days Number of days to look back + * @param string|null $jobTask Filter by specific job task + * + * @return array{grid: array>, summary: array} + */ + public function getHeatmapData(string $field = 'created', int $days = 30, ?string $jobTask = null): array { + $driverName = $this->getDriverName(); + $since = (new DateTime())->subDays($days); + + $query = $this->find(); + + // Build day of week and hour expressions based on driver + switch ($driverName) { + case static::DRIVER_POSTGRES: + $dayOfWeek = $query->newExpr("EXTRACT(DOW FROM {$field})"); + $hourOfDay = $query->newExpr("EXTRACT(HOUR FROM {$field})"); + + break; + case static::DRIVER_SQLSERVER: + $dayOfWeek = $query->newExpr("DATEPART(WEEKDAY, {$field}) - 1"); + $hourOfDay = $query->newExpr("DATEPART(HOUR, {$field})"); + + break; + case static::DRIVER_SQLITE: + $dayOfWeek = $query->newExpr("CAST(strftime('%w', {$field}) AS INTEGER)"); + $hourOfDay = $query->newExpr("CAST(strftime('%H', {$field}) AS INTEGER)"); + + break; + default: // MySQL + $dayOfWeek = $query->newExpr("DAYOFWEEK({$field})"); + $hourOfDay = $query->newExpr("HOUR({$field})"); + + break; + } + + $conditions = [ + $field . ' IS NOT' => null, + $field . ' >=' => $since, + ]; + if ($jobTask) { + $conditions['job_task'] = $jobTask; + } + + /** @var array $results */ + $results = $query + ->select([ + 'day_of_week' => $dayOfWeek, + 'hour_of_day' => $hourOfDay, + 'job_count' => $query->func()->count('*'), + ]) + ->where($conditions) + ->groupBy(['day_of_week', 'hour_of_day']) + ->disableHydration() + ->toArray(); + + // Initialize 7x24 grid (days x hours) with zeros + $grid = []; + for ($day = 0; $day < 7; $day++) { + $grid[$day] = array_fill(0, 24, 0); + } + + // Populate grid with results + $totalJobs = 0; + $peakCount = 0; + $peakDay = 0; + $peakHour = 0; + + foreach ($results as $row) { + $day = (int)$row['day_of_week']; + $hour = (int)$row['hour_of_day']; + $count = (int)$row['job_count']; + + // MySQL DAYOFWEEK returns 1=Sunday, 2=Monday, etc. Adjust to 0=Sunday + if ($driverName === static::DRIVER_MYSQL) { + $day = $day - 1; + } + + // Ensure day is in valid range (0-6) + $day = max(0, min(6, $day)); + + $grid[$day][$hour] = $count; + $totalJobs += $count; + + if ($count > $peakCount) { + $peakCount = $count; + $peakDay = $day; + $peakHour = $hour; + } + } + + // Calculate summary statistics + $dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + $busiestDay = $dayNames[0]; + $dayCounts = []; + foreach ($grid as $day => $hours) { + $dayCounts[$day] = array_sum($hours); + } + $maxDayCount = 0; + foreach ($dayCounts as $day => $count) { + if ($count > $maxDayCount) { + $maxDayCount = $count; + $busiestDay = $dayNames[$day]; + } + } + + // Find quietest hour + $quietestCount = PHP_INT_MAX; + $quietestDay = 0; + $quietestHour = 0; + foreach ($grid as $day => $hours) { + foreach ($hours as $hour => $count) { + if ($count < $quietestCount) { + $quietestCount = $count; + $quietestDay = $day; + $quietestHour = $hour; + } + } + } + + return [ + 'grid' => $grid, + 'summary' => [ + 'total' => $totalJobs, + 'avgPerHour' => $totalJobs > 0 ? round($totalJobs / ($days * 24), 1) : 0, + 'peakHour' => sprintf('%s %02d:00', $dayNames[$peakDay], $peakHour), + 'peakCount' => $peakCount, + 'quietestHour' => sprintf('%s %02d:00', $dayNames[$quietestDay], $quietestHour), + 'quietestCount' => $quietestCount === PHP_INT_MAX ? 0 : $quietestCount, + 'busiestDay' => $busiestDay, + 'days' => $days, + ], + ]; + } + /** * Return some statistics about unfinished jobs still in the Database. * diff --git a/src/View/Helper/QueueHelper.php b/src/View/Helper/QueueHelper.php index 19d83c15..0d75de4e 100644 --- a/src/View/Helper/QueueHelper.php +++ b/src/View/Helper/QueueHelper.php @@ -214,4 +214,31 @@ public function formatInterval(DateInterval $interval): string { return implode(' ', $parts); } + /** + * Returns a color for heatmap visualization based on intensity. + * + * Uses a green gradient from light (low activity) to dark (high activity). + * + * @param float $intensity Value between 0 and 1 + * + * @return string CSS color value + */ + public function heatmapColor(float $intensity): string { + if ($intensity <= 0) { + return '#ebedf0'; // Empty/no activity + } + + // Green gradient similar to GitHub contribution graph + $colors = [ + '#9be9a8', // Light green (low) + '#40c463', // Medium green + '#30a14e', // Darker green + '#216e39', // Dark green (high) + ]; + + $index = min((int)floor($intensity * count($colors)), count($colors) - 1); + + return $colors[$index]; + } + } diff --git a/templates/Admin/Queue/index.php b/templates/Admin/Queue/index.php index d751595d..c43677cc 100644 --- a/templates/Admin/Queue/index.php +++ b/templates/Admin/Queue/index.php @@ -324,11 +324,16 @@ -
+ +
+
+ +
+
+ Html->link( + '' . __d('queue', 'Time Series'), + ['action' => 'stats'], + ['class' => 'list-group-item list-group-item-action active', 'escapeTitle' => false] + ) ?> + Html->link( + '' . __d('queue', 'Heatmap'), + ['action' => 'heatmap'], + ['class' => 'list-group-item list-group-item-action', 'escapeTitle' => false] + ) ?> + Html->link( + '' . __d('queue', 'Back to Dashboard'), + ['controller' => 'Queue', 'action' => 'index'], + ['class' => 'list-group-item list-group-item-action', 'escapeTitle' => false] + ) ?> +
+
+ +
From 4666857cddb18e7f71ac155e942c67b9fc37a1c9 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 18 Mar 2026 06:04:05 +0100 Subject: [PATCH 2/2] Address PR review comments - Add whitelist validation for $field parameter to prevent SQL injection - Fix SQL Server DATEFIRST issue using DATEDIFF calculation - Fix XSS in tooltip by escaping values with h() - Remove duplicate tooltip initialization (handled in layout) - Use expr() instead of deprecated newExpr() - Add comprehensive tests for getHeatmapData() method - Add tests for heatmapColor() helper method - Add controller tests for heatmap() action --- src/Model/Table/QueuedJobsTable.php | 23 ++++--- templates/Admin/QueuedJobs/heatmap.php | 23 +++---- .../Admin/QueuedJobsControllerTest.php | 58 ++++++++++++++++ .../Model/Table/QueuedJobsTableTest.php | 67 +++++++++++++++++++ .../TestCase/View/Helper/QueueHelperTest.php | 28 ++++++++ 5 files changed, 177 insertions(+), 22 deletions(-) diff --git a/src/Model/Table/QueuedJobsTable.php b/src/Model/Table/QueuedJobsTable.php index da23d6ea..9c9969bc 100644 --- a/src/Model/Table/QueuedJobsTable.php +++ b/src/Model/Table/QueuedJobsTable.php @@ -872,6 +872,12 @@ public function clone(QueuedJob $queuedJob): ?QueuedJob { * @return array{grid: array>, summary: array} */ public function getHeatmapData(string $field = 'created', int $days = 30, ?string $jobTask = null): array { + // Whitelist allowed fields to prevent SQL injection + $allowedFields = ['created', 'completed']; + if (!in_array($field, $allowedFields, true)) { + $field = 'created'; + } + $driverName = $this->getDriverName(); $since = (new DateTime())->subDays($days); @@ -880,23 +886,24 @@ public function getHeatmapData(string $field = 'created', int $days = 30, ?strin // Build day of week and hour expressions based on driver switch ($driverName) { case static::DRIVER_POSTGRES: - $dayOfWeek = $query->newExpr("EXTRACT(DOW FROM {$field})"); - $hourOfDay = $query->newExpr("EXTRACT(HOUR FROM {$field})"); + $dayOfWeek = $query->expr("EXTRACT(DOW FROM {$field})"); + $hourOfDay = $query->expr("EXTRACT(HOUR FROM {$field})"); break; case static::DRIVER_SQLSERVER: - $dayOfWeek = $query->newExpr("DATEPART(WEEKDAY, {$field}) - 1"); - $hourOfDay = $query->newExpr("DATEPART(HOUR, {$field})"); + // Use DATEDIFF against known Sunday (1900-01-07) for consistent day-of-week regardless of DATEFIRST setting + $dayOfWeek = $query->expr("DATEDIFF(DAY, '1900-01-07', CAST({$field} AS DATE)) % 7"); + $hourOfDay = $query->expr("DATEPART(HOUR, {$field})"); break; case static::DRIVER_SQLITE: - $dayOfWeek = $query->newExpr("CAST(strftime('%w', {$field}) AS INTEGER)"); - $hourOfDay = $query->newExpr("CAST(strftime('%H', {$field}) AS INTEGER)"); + $dayOfWeek = $query->expr("CAST(strftime('%w', {$field}) AS INTEGER)"); + $hourOfDay = $query->expr("CAST(strftime('%H', {$field}) AS INTEGER)"); break; default: // MySQL - $dayOfWeek = $query->newExpr("DAYOFWEEK({$field})"); - $hourOfDay = $query->newExpr("HOUR({$field})"); + $dayOfWeek = $query->expr("DAYOFWEEK({$field})"); + $hourOfDay = $query->expr("HOUR({$field})"); break; } diff --git a/templates/Admin/QueuedJobs/heatmap.php b/templates/Admin/QueuedJobs/heatmap.php index 36f521ba..c96a89f9 100644 --- a/templates/Admin/QueuedJobs/heatmap.php +++ b/templates/Admin/QueuedJobs/heatmap.php @@ -93,16 +93,23 @@ $intensity = $maxValue > 0 ? $count / $maxValue : 0; $bgColor = $this->Queue->heatmapColor($intensity); $textColor = $intensity > 0.5 ? '#fff' : '#333'; + $tooltipTitle = sprintf( + '%s %s
%s %s', + h($dayNamesFull[$day]), + sprintf('%02d:00', $hour), + number_format($count), + h(__d('queue', 'jobs')), + ); ?>
+ title=""> 0) { ?> 999 ? round($count / 1000, 1) . 'k' : $count ?> @@ -289,15 +296,3 @@ } } - -append('script'); ?> - -end(); diff --git a/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php b/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php index f4770d33..2c54e5b1 100644 --- a/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php +++ b/tests/TestCase/Controller/Admin/QueuedJobsControllerTest.php @@ -146,6 +146,64 @@ public function testStats() { $this->assertResponseCode(200); } + /** + * @return void + */ + public function testHeatmap(): void { + Configure::write('Queue.isStatisticEnabled', true); + + $this->get(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'QueuedJobs', 'action' => 'heatmap']); + + $this->assertResponseCode(200); + } + + /** + * @return void + */ + public function testHeatmapNotEnabled(): void { + Configure::write('Queue.isStatisticEnabled', false); + + $this->expectException(NotFoundException::class); + + $this->get(['prefix' => 'Admin', 'plugin' => 'Queue', 'controller' => 'QueuedJobs', 'action' => 'heatmap']); + } + + /** + * @return void + */ + public function testHeatmapWithFilters(): void { + Configure::write('Queue.isStatisticEnabled', true); + $this->createJob(['job_task' => 'Queue.Example']); + + $this->get([ + 'prefix' => 'Admin', + 'plugin' => 'Queue', + 'controller' => 'QueuedJobs', + 'action' => 'heatmap', + '?' => [ + 'metric' => 'completed', + 'days' => 7, + 'job_type' => 'Queue.Example', + ], + ]); + + $this->assertResponseCode(200); + + $heatmapData = $this->viewVariable('heatmapData'); + $this->assertArrayHasKey('grid', $heatmapData); + $this->assertArrayHasKey('summary', $heatmapData); + $this->assertCount(7, $heatmapData['grid']); // 7 days of week + + $metric = $this->viewVariable('metric'); + $this->assertSame('completed', $metric); + + $days = $this->viewVariable('days'); + $this->assertSame(7, $days); + + $jobType = $this->viewVariable('jobType'); + $this->assertSame('Queue.Example', $jobType); + } + /** * Test index method * diff --git a/tests/TestCase/Model/Table/QueuedJobsTableTest.php b/tests/TestCase/Model/Table/QueuedJobsTableTest.php index 913cc348..fbdddb55 100644 --- a/tests/TestCase/Model/Table/QueuedJobsTableTest.php +++ b/tests/TestCase/Model/Table/QueuedJobsTableTest.php @@ -754,6 +754,73 @@ public function testGetStats() { $this->assertWithinRange(7200, (int)$queuedJob->fetchdelay, 1); } + /** + * @return void + */ + public function testGetHeatmapData(): void { + // Create jobs with different timestamps + $now = new DateTime(); + + $queuedJob = $this->QueuedJobs->newEntity([ + 'job_task' => 'Foo', + 'created' => $now->subHours(2), + 'completed' => $now->subHours(1), + ]); + $this->QueuedJobs->saveOrFail($queuedJob); + + $queuedJob2 = $this->QueuedJobs->newEntity([ + 'job_task' => 'Foo', + 'created' => $now->subHours(3), + 'completed' => $now->subHours(2), + ]); + $this->QueuedJobs->saveOrFail($queuedJob2); + + $result = $this->QueuedJobs->getHeatmapData('created', 30); + + // Check grid structure is 7x24 + $this->assertArrayHasKey('grid', $result); + $this->assertArrayHasKey('summary', $result); + $this->assertCount(7, $result['grid']); + foreach ($result['grid'] as $hours) { + $this->assertCount(24, $hours); + } + + // Check summary fields + $this->assertArrayHasKey('total', $result['summary']); + $this->assertArrayHasKey('avgPerHour', $result['summary']); + $this->assertArrayHasKey('peakHour', $result['summary']); + $this->assertArrayHasKey('peakCount', $result['summary']); + $this->assertArrayHasKey('quietestHour', $result['summary']); + $this->assertArrayHasKey('quietestCount', $result['summary']); + $this->assertArrayHasKey('busiestDay', $result['summary']); + $this->assertArrayHasKey('days', $result['summary']); + + $this->assertSame(2, $result['summary']['total']); + $this->assertSame(30, $result['summary']['days']); + } + + /** + * @return void + */ + public function testGetHeatmapDataFieldWhitelist(): void { + // Create a job + $now = new DateTime(); + $queuedJob = $this->QueuedJobs->newEntity([ + 'job_task' => 'Foo', + 'created' => $now, + 'completed' => $now, + ]); + $this->QueuedJobs->saveOrFail($queuedJob); + + // Test with valid field + $result = $this->QueuedJobs->getHeatmapData('completed', 30); + $this->assertSame(1, $result['summary']['total']); + + // Test with invalid field (should fall back to 'created') + $result = $this->QueuedJobs->getHeatmapData('invalid_field', 30); + $this->assertSame(1, $result['summary']['total']); + } + /** * Test that Queue.Job.created event is fired when a job is created. * diff --git a/tests/TestCase/View/Helper/QueueHelperTest.php b/tests/TestCase/View/Helper/QueueHelperTest.php index 94baa09e..bc9ff064 100644 --- a/tests/TestCase/View/Helper/QueueHelperTest.php +++ b/tests/TestCase/View/Helper/QueueHelperTest.php @@ -250,4 +250,32 @@ public function testFormatInterval(): void { $this->assertSame('1d 2h 30m 15s', $this->QueueHelper->formatInterval($interval)); } + /** + * @return void + */ + public function testHeatmapColor(): void { + // Zero or negative intensity returns empty/no-activity color + $this->assertSame('#ebedf0', $this->QueueHelper->heatmapColor(0)); + $this->assertSame('#ebedf0', $this->QueueHelper->heatmapColor(-0.5)); + + // Low intensity returns lightest green + $this->assertSame('#9be9a8', $this->QueueHelper->heatmapColor(0.1)); + $this->assertSame('#9be9a8', $this->QueueHelper->heatmapColor(0.24)); + + // Medium-low intensity + $this->assertSame('#40c463', $this->QueueHelper->heatmapColor(0.25)); + $this->assertSame('#40c463', $this->QueueHelper->heatmapColor(0.49)); + + // Medium-high intensity + $this->assertSame('#30a14e', $this->QueueHelper->heatmapColor(0.5)); + $this->assertSame('#30a14e', $this->QueueHelper->heatmapColor(0.74)); + + // High intensity returns darkest green + $this->assertSame('#216e39', $this->QueueHelper->heatmapColor(0.75)); + $this->assertSame('#216e39', $this->QueueHelper->heatmapColor(1.0)); + + // Values > 1 should clamp to darkest + $this->assertSame('#216e39', $this->QueueHelper->heatmapColor(1.5)); + } + }