TenantAtlas/app/Support/OpsUx/RunDurationInsights.php
2026-01-18 14:44:16 +01:00

146 lines
3.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Support\OperationCatalog;
use Illuminate\Support\Facades\Cache;
final class RunDurationInsights
{
public static function elapsedSeconds(OperationRun $run): ?int
{
$start = $run->started_at ?? $run->created_at;
if (! $start) {
return null;
}
$end = $run->completed_at ?? now();
$seconds = $end->diffInSeconds($start);
if (is_int($seconds)) {
return $seconds;
}
return (int) round((float) $seconds);
}
public static function elapsedHuman(OperationRun $run): string
{
$start = $run->started_at ?? $run->created_at;
if (! $start) {
return '—';
}
$end = $run->completed_at ?? now();
return $end->diffForHumans($start, true);
}
public static function expectedSeconds(OperationRun $run): ?int
{
$catalog = OperationCatalog::expectedDurationSeconds((string) $run->type);
if (is_int($catalog)) {
return $catalog;
}
$tenantId = (int) ($run->tenant_id ?? 0);
$type = (string) ($run->type ?? '');
if ($tenantId <= 0 || $type === '') {
return null;
}
$cacheKey = "opsux:expected-duration:tenant:{$tenantId}:type:".$type;
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($tenantId, $type): ?int {
$durations = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', $type)
->whereNotNull('started_at')
->whereNotNull('completed_at')
->where('created_at', '>=', now()->subDays(30))
->latest('id')
->limit(200)
->get(['started_at', 'completed_at'])
->map(function (OperationRun $run): ?int {
if (! $run->started_at || ! $run->completed_at) {
return null;
}
$seconds = $run->completed_at->diffInSeconds($run->started_at);
if (is_int($seconds)) {
return $seconds;
}
return (int) round((float) $seconds);
})
->filter(fn (?int $seconds): bool => is_int($seconds) && $seconds > 0)
->sort()
->values();
if ($durations->isEmpty()) {
return null;
}
$count = $durations->count();
$middle = intdiv($count - 1, 2);
$median = (int) $durations[$middle];
return $median > 0 ? $median : null;
});
}
public static function expectedHuman(OperationRun $run): ?string
{
$seconds = self::expectedSeconds($run);
if (! is_int($seconds) || $seconds <= 0) {
return null;
}
if ($seconds < 60) {
return 'Typically under 1 minute';
}
$minutes = (int) round($seconds / 60);
return "Typically ~{$minutes} min";
}
public static function stuckGuidance(OperationRun $run): ?string
{
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
if (! in_array($uxStatus, ['queued', 'running'], true)) {
return null;
}
$elapsed = self::elapsedSeconds($run);
if (! is_int($elapsed) || $elapsed <= 0) {
return null;
}
$expected = self::expectedSeconds($run);
$isLikelyStuck = is_int($expected)
? ($elapsed > max(600, $expected * 2))
: ($elapsed > 900);
if (! $isLikelyStuck) {
return null;
}
return 'Taking longer than expected. If this does not complete soon, verify the queue worker is running and check logs for errors.';
}
}