TenantAtlas/app/Support/OpsUx/RunDurationInsights.php
ahmido bd6df1f343 055-ops-ux-rollout (#64)
Kurzbeschreibung

Implementiert Feature 055 — Ops‑UX Constitution Rollout v1.3.0.
Behebt: globales BulkOperationProgress-Widget benötigt keinen manuellen Refresh mehr; ETA/Elapsed aktualisieren korrekt; Widget verschwindet automatisch.
Verbesserungen: zuverlässiges polling (Alpine factory + Livewire fallback), sofortiger Enqueue‑Signal-Dispatch, Failure‑Message‑Sanitization, neue Guard‑ und Regressionstests, Specs/Tasks aktualisiert.
Was geändert wurde (Auszug)

InventoryLanding.php
bulk-operation-progress.blade.php
OperationUxPresenter.php
SyncRestoreRunToOperationRun.php
PolicyResource.php
PolicyVersionResource.php
RestoreRunResource.php
tests/Feature/OpsUx/* (PollerRegistration, TerminalNotificationFailureMessageTest, CanonicalViewRunLinksTest, OperationCatalogCoverageTest, UnknownOperationTypeLabelTest)
InventorySyncButtonTest.php
tasks.md
Tests

Neue Tests hinzugefügt; php artisan test --group=ops-ux lokal grün (alle relevanten Tests laufen).
How to verify manually

Auf Branch wechseln: 055-ops-ux-rollout
In Filament: Inventory → Sync (oder relevante Bulk‑Aktion) auslösen.
Beobachten: Progress‑Widget erscheint sofort, ETA/Elapsed aktualisiert, Widget verschwindet nach Fertigstellung ohne Browser‑Refresh.
Optional: ./vendor/bin/sail exec app php artisan test --filter=OpsUx oder php artisan test --group=ops-ux
Besonderheiten / Hinweise

Einzelne, synchrone Policy‑Actions (ignore/restore/PolicyVersion single archive/restore/forceDelete) sind absichtlich inline und erzeugen kein OperationRun. Bulk‑Aktionen und restore.execute werden als Runs modelliert. Wenn gewünscht, kann ich die inline‑Actions auf OperationRunService umstellen, damit sie in Monitoring → Operations sichtbar werden.
Remote: Branch ist bereits gepusht (origin/055-ops-ux-rollout). PR kann in Gitea erstellt werden.
Links

Specs & tasks: tasks.md
Monitoring page: Operations.php

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #64
2026-01-18 14:50:15 +00: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.';
}
}