TenantAtlas/resources/views/livewire/bulk-operation-progress.blade.php
ahmido 971105daa9 057-filament-v5-upgrade (#66)
Summary: Upgrade Filament to v5 (Livewire v4), replace Filament v4-only plugins, add first-party JSON renderer, and harden Monitoring/Ops UX guardrails.
What I changed:
Composer: upgraded filament/filament → v5, removed pepperfm/filament-json and lara-zeus/torch-filament, added torchlight/engine.
Views: replaced JSON viewer with json-viewer.blade.php and updated snapshot display.
Tests: added DB-only + tenant-isolation guard tests under Monitoring and OpsUx, plus Filament smoke tests.
Specs: added/updated specs/057-filament-v5-upgrade/* (spec, tasks, plan, quickstart, research).
Formatting: ran Pint; ran full test suite (641 passed, 5 skipped).
Validation:
Ran ./vendor/bin/sail artisan test (full suite) — all tests passed.
Ran ./vendor/bin/sail pint --dirty — formatting applied.
Ran npm run build locally (Vite) — assets generated.
Notes / Rollback:
Rollback: revert composer.json/composer.lock and build assets; documented in quickstart.md.
One pending app migration was noted during validation; ensure migrations are applied in staging before deploy.
Reviewers: @frontend, @backend (adjust as needed)
Spec links:
spec.md
tasks.md
quickstart.md

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #66
2026-01-20 21:19:27 +00:00

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>