Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/Controller/Admin/QueuedJobsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
Comment on lines +116 to +146

/**
* View method
*
Expand Down
145 changes: 145 additions & 0 deletions src/Model/Table/QueuedJobsTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,151 @@ 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<int, array<int, int>>, summary: array<string, mixed>}
*/
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);

$query = $this->find();

// Build day of week and hour expressions based on driver
switch ($driverName) {
case static::DRIVER_POSTGRES:
$dayOfWeek = $query->expr("EXTRACT(DOW FROM {$field})");
$hourOfDay = $query->expr("EXTRACT(HOUR FROM {$field})");

break;
case static::DRIVER_SQLSERVER:
// 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->expr("CAST(strftime('%w', {$field}) AS INTEGER)");
$hourOfDay = $query->expr("CAST(strftime('%H', {$field}) AS INTEGER)");

break;
default: // MySQL
$dayOfWeek = $query->expr("DAYOFWEEK({$field})");
$hourOfDay = $query->expr("HOUR({$field})");

break;
}

$conditions = [
$field . ' IS NOT' => null,
$field . ' >=' => $since,
];
if ($jobTask) {
$conditions['job_task'] = $jobTask;
}

/** @var array<array{day_of_week: int, hour_of_day: int, job_count: int}> $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,
],
];
}
Comment on lines +865 to +1008

/**
* Return some statistics about unfinished jobs still in the Database.
*
Expand Down
27 changes: 27 additions & 0 deletions src/View/Helper/QueueHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Comment on lines +217 to +242

}
11 changes: 8 additions & 3 deletions templates/Admin/Queue/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,16 @@
</div>
</div>
<?php if (Configure::read('Queue.isStatisticEnabled')): ?>
<div class="card-footer">
<div class="card-footer d-flex gap-3">
<?= $this->Html->link(
__d('queue', 'Detailed Statistics'),
'<i class="fas fa-chart-line me-1"></i>' . __d('queue', 'Time Series'),
['controller' => 'QueuedJobs', 'action' => 'stats'],
['class' => 'text-decoration-none']
['class' => 'text-decoration-none', 'escapeTitle' => false]
) ?>
<?= $this->Html->link(
'<i class="fas fa-th me-1"></i>' . __d('queue', 'Heatmap'),
['controller' => 'QueuedJobs', 'action' => 'heatmap'],
['class' => 'text-decoration-none', 'escapeTitle' => false]
) ?>
</div>
<?php endif; ?>
Expand Down
Loading
Loading