224 lines
7.9 KiB
PHP
224 lines
7.9 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,
|
|
teardownObserver: 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);
|
|
|
|
// Ensure we always detach listeners when Livewire/Filament removes this
|
|
// element (for example during modal unmounts or navigation/morph).
|
|
this.teardownObserver = new MutationObserver(() => {
|
|
if (!this.$el || this.$el.isConnected !== true) {
|
|
this.destroy();
|
|
}
|
|
});
|
|
|
|
this.teardownObserver.observe(document.body, { childList: true, subtree: true });
|
|
|
|
// First sync immediately.
|
|
this.schedule(0);
|
|
},
|
|
|
|
destroy() {
|
|
this.stop();
|
|
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
window.removeEventListener('livewire:navigated', this.onNavigated);
|
|
|
|
if (this.teardownObserver) {
|
|
this.teardownObserver.disconnect();
|
|
this.teardownObserver = null;
|
|
}
|
|
},
|
|
|
|
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;
|
|
}
|
|
|
|
try {
|
|
await this.$wire.refreshRuns();
|
|
} catch (error) {
|
|
// Livewire will reject pending action promises when requests are
|
|
// cancelled (for example during `wire:navigate`). That's expected
|
|
// for this background poller, so we swallow cancellation rejections.
|
|
const isCancellation = Boolean(
|
|
error &&
|
|
typeof error === 'object' &&
|
|
error.status === null &&
|
|
error.body === null &&
|
|
error.json === null &&
|
|
error.errors === null,
|
|
);
|
|
|
|
if (!isCancellation) {
|
|
console.warn('Ops UX widget refreshRuns failed', error);
|
|
}
|
|
}
|
|
|
|
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().catch(() => {});
|
|
}, delay);
|
|
},
|
|
};
|
|
};
|
|
</script>
|