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
190 lines
6.4 KiB
PHP
190 lines
6.4 KiB
PHP
@php($runs = $runs ?? collect())
|
|
@php($overflowCount = (int) ($overflowCount ?? 0))
|
|
@php($tenant = $tenant ?? null)
|
|
|
|
{{-- Widget must always be mounted, even when empty, so it can receive Livewire events --}}
|
|
<div
|
|
x-data="opsUxProgressWidgetPoller()"
|
|
x-init="init()"
|
|
wire:key="ops-ux-progress-widget"
|
|
@if($runs->isNotEmpty()) wire:poll.5s="refreshRuns" @endif
|
|
>
|
|
@if($runs->isNotEmpty())
|
|
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
|
@foreach ($runs->take(5) as $run)
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
|
|
wire:key="run-{{ $run->id }}">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="min-w-0">
|
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
|
|
</h4>
|
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
@if($run->status === 'queued')
|
|
Queued • {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }}
|
|
@else
|
|
Running • {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }}
|
|
@endif
|
|
</p>
|
|
</div>
|
|
|
|
@if ($tenant)
|
|
<a
|
|
href="{{ \App\Support\OpsUx\OperationRunUrl::view($run, $tenant) }}"
|
|
class="shrink-0 text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
|
>
|
|
View run
|
|
</a>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
|
|
@if($overflowCount > 0 && $tenant)
|
|
<a
|
|
href="{{ \App\Support\OpsUx\OperationRunUrl::index($tenant) }}"
|
|
class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center text-xs font-medium text-gray-700 hover:text-gray-900 dark:text-gray-200 dark:hover:text-white shadow"
|
|
>
|
|
+{{ $overflowCount }} more
|
|
</a>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<script>
|
|
window.opsUxProgressWidgetPoller ??= function opsUxProgressWidgetPoller() {
|
|
return {
|
|
timer: null,
|
|
activeSinceMs: null,
|
|
fastUntilMs: null,
|
|
|
|
init() {
|
|
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
|
window.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
|
|
this.onNavigated = this.onNavigated.bind(this);
|
|
window.addEventListener('livewire:navigated', this.onNavigated);
|
|
|
|
// First sync immediately.
|
|
this.schedule(0);
|
|
},
|
|
|
|
destroy() {
|
|
this.stop();
|
|
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
window.removeEventListener('livewire:navigated', this.onNavigated);
|
|
},
|
|
|
|
stop() {
|
|
if (this.timer) {
|
|
clearTimeout(this.timer);
|
|
this.timer = null;
|
|
}
|
|
},
|
|
|
|
isModalOpen() {
|
|
return document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
|
|
},
|
|
|
|
isPaused() {
|
|
if (document.hidden === true) {
|
|
return true;
|
|
}
|
|
|
|
if (this.isModalOpen()) {
|
|
return true;
|
|
}
|
|
|
|
if (!this.$el || this.$el.isConnected !== true) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
onVisibilityChange() {
|
|
if (!this.isPaused()) {
|
|
this.schedule(0);
|
|
}
|
|
},
|
|
|
|
onNavigated() {
|
|
if (!this.isPaused()) {
|
|
this.schedule(0);
|
|
}
|
|
},
|
|
|
|
activeAgeSeconds() {
|
|
if (this.activeSinceMs === null) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.floor((Date.now() - this.activeSinceMs) / 1000);
|
|
},
|
|
|
|
nextIntervalMs() {
|
|
// Stop polling entirely if server says this component is disabled.
|
|
if (this.$wire?.disabled === true) {
|
|
return null;
|
|
}
|
|
|
|
// Discovery polling.
|
|
if (this.$wire?.hasActiveRuns !== true) {
|
|
this.activeSinceMs = null;
|
|
return 30_000;
|
|
}
|
|
|
|
// Active polling backoff.
|
|
if (this.activeSinceMs === null) {
|
|
this.activeSinceMs = Date.now();
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (this.fastUntilMs && now < this.fastUntilMs) {
|
|
return 1_000;
|
|
}
|
|
|
|
const age = this.activeAgeSeconds();
|
|
if (age < 10) {
|
|
return 1_000;
|
|
}
|
|
|
|
if (age < 60) {
|
|
return 5_000;
|
|
}
|
|
|
|
return 10_000;
|
|
},
|
|
|
|
async tick() {
|
|
if (this.isPaused()) {
|
|
// Keep it calm: check again later.
|
|
this.schedule(2_000);
|
|
return;
|
|
}
|
|
|
|
await this.$wire.refreshRuns();
|
|
|
|
const next = this.nextIntervalMs();
|
|
if (next === null) {
|
|
this.stop();
|
|
return;
|
|
}
|
|
|
|
this.schedule(next);
|
|
},
|
|
|
|
schedule(delayMs) {
|
|
this.stop();
|
|
|
|
const delay = Math.max(0, Number(delayMs ?? 0));
|
|
|
|
this.timer = setTimeout(() => {
|
|
this.tick();
|
|
}, delay);
|
|
},
|
|
};
|
|
};
|
|
</script>
|