TenantAtlas/apps/platform/resources/views/livewire/bulk-operation-progress.blade.php
ahmido ca61fa17dc 270-operationrun-progress-contract: OperationRun progress contract (#325)
Automated PR created by Copilot agent: commits workspace changes, adds specs and tests.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #325
2026-05-04 16:36:50 +00:00

251 lines
18 KiB
PHP

@php
$runs = $runs ?? collect();
$overflowCount = (int) ($overflowCount ?? 0);
$tenant = $tenant ?? null;
$visibleRunCount = $runs->count();
$activeRunCount = (int) ($activeRunCount ?? ($runs->filter(fn ($run): bool => $run instanceof \App\Models\OperationRun && $run->isCurrentlyActive())->count() + $overflowCount));
$primaryRun = $runs->first();
$hasActiveVisibleRuns = $runs->contains(fn ($run): bool => $run instanceof \App\Models\OperationRun && $run->isCurrentlyActive());
$hasTerminalFollowUpVisibleRuns = $runs->contains(fn ($run): bool => $run instanceof \App\Models\OperationRun && ! $run->isCurrentlyActive() && $run->requiresOperatorReview());
$hasTerminalVisibleRuns = $runs->contains(fn ($run): bool => $run instanceof \App\Models\OperationRun && ! $run->isCurrentlyActive());
$usesCollectivePrimaryAction = $visibleRunCount > 1;
$operationsCollectionLabel = \App\Support\OperationRunLinks::collectionLabel();
$operationsIndexUrl = $tenant ? \App\Support\OpsUx\OperationRunUrl::index($tenant) : null;
$primaryActionLabel = $usesCollectivePrimaryAction ? 'Review operations' : 'View operation';
$bannerTitle = $hasActiveVisibleRuns ? 'Active operations' : 'Operation updates';
$bannerHelper = match (true) {
$hasActiveVisibleRuns && $hasTerminalFollowUpVisibleRuns => 'Active and recent operation updates that may need review.',
$hasActiveVisibleRuns => 'Queued and running work stays here until diagnostics are needed.',
$hasTerminalFollowUpVisibleRuns => 'Recent operation updates that may need review.',
default => 'Recent operation updates.',
};
$primaryActionUrl = null;
if ($usesCollectivePrimaryAction) {
$primaryActionUrl = $operationsIndexUrl;
} elseif ($tenant && $primaryRun) {
$primaryActionUrl = \App\Support\OpsUx\OperationRunUrl::view($primaryRun, $tenant);
}
$tertiaryActionLabel = $hasActiveVisibleRuns
? 'Hide activity'
: ($hasTerminalFollowUpVisibleRuns ? ($visibleRunCount > 1 ? 'Dismiss updates' : 'Dismiss') : 'Dismiss');
@endphp
{{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}}
{{-- 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"
data-testid="ops-ux-activity-feedback-root"
data-tenant-id="{{ $tenant?->getKey() }}"
@if (! $disabled && $hasVisibleRuns)
wire:poll.10s="refreshRuns"
@endif
>
@if($runs->isNotEmpty())
<section
class="tp-ops-activity-banner mt-5 mb-7 w-full rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3 dark:border-white/10 dark:bg-gray-950/90"
data-testid="ops-ux-activity-feedback-banner"
x-cloak
x-show="$wire.hasVisibleRuns === true && (! isCollapsed || $wire.hasActiveRuns === true)"
>
<div class="tp-ops-activity-layout flex flex-col gap-3">
<div x-cloak x-show="! isCollapsed">
<div class="tp-ops-activity-header flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between lg:gap-4">
<div class="tp-ops-activity-header-copy flex min-w-0 flex-1 items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary-50 text-primary-600 ring-1 ring-primary-100 dark:bg-primary-500/15 dark:text-primary-300 dark:ring-primary-400/20">
<x-filament::icon icon="heroicon-m-bolt" class="h-4 w-4" />
</div>
<div class="tp-ops-activity-copy-stack min-w-0 flex-1 space-y-1">
<p class="tp-ops-activity-title text-xs font-semibold uppercase tracking-[0.12em] whitespace-nowrap text-primary-700 dark:text-primary-300" data-testid="ops-ux-activity-feedback-title">{{ $bannerTitle }}</p>
<p class="tp-ops-activity-helper text-[13px] leading-[1.45] whitespace-normal break-words text-gray-500 dark:text-gray-400" data-testid="ops-ux-activity-feedback-helper">{{ $bannerHelper }}</p>
</div>
</div>
<div
class="tp-ops-activity-actions flex shrink-0 flex-wrap items-center gap-1.5 lg:flex-none lg:flex-nowrap lg:justify-end"
data-testid="ops-ux-activity-feedback-actions"
>
@if ($primaryActionUrl)
<a
href="{{ $primaryActionUrl }}"
class="inline-flex items-center justify-center rounded-lg bg-primary-600 px-2.5 py-2 text-xs font-medium text-white shadow-sm transition hover:bg-primary-500 dark:bg-primary-500 dark:hover:bg-primary-400"
data-testid="ops-ux-activity-feedback-primary-action"
>
{{ $primaryActionLabel }}
</a>
@endif
@if ($operationsIndexUrl)
<a
href="{{ $operationsIndexUrl }}"
class="inline-flex items-center justify-center rounded-lg border border-gray-200 px-2.5 py-2 text-xs font-medium text-gray-700 transition hover:border-gray-300 hover:text-gray-900 dark:border-white/10 dark:text-gray-200 dark:hover:border-white/20 dark:hover:text-white"
data-testid="ops-ux-activity-feedback-secondary-action"
>
Show all operations
</a>
@endif
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-transparent px-2.5 py-2 text-xs font-medium text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
x-on:click="collapse()"
data-testid="ops-ux-activity-feedback-toggle"
>
{{ $tertiaryActionLabel }}
</button>
</div>
</div>
<div class="tp-ops-activity-summary mt-3 min-w-0 sm:pl-[3.25rem]" data-testid="ops-ux-activity-feedback-summary">
<div class="space-y-3" data-testid="ops-ux-activity-feedback-list">
@foreach ($runs as $run)
@php
$uxStatus = \App\Support\OpsUx\OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
$isTerminalRun = ! $run->isCurrentlyActive();
$progress = \App\Support\OpsUx\OperationRunProgressContract::forRun($run);
$hasDeterminateProgress = $progress['display'] === 'counted';
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
$showsLifecycleAttention = $lifecycleAttention !== null
&& ($lifecycleAttention !== 'Likely stale' || $run->status === 'queued' || ! $hasDeterminateProgress);
$progressLabel = $progress['label'];
$progressPercent = $progress['percent'];
$showsIndeterminateProgress = $progress['display'] === 'indeterminate';
$statusLabel = match ($uxStatus) {
'queued' => 'Queued for execution',
'running' => 'In progress',
'succeeded' => 'Completed successfully',
'partial' => 'Completed with follow-up',
'blocked' => 'Blocked by prerequisite',
default => 'Execution failed',
};
$statusClasses = match ($uxStatus) {
'queued' => 'bg-primary-50 text-primary-700 ring-1 ring-inset ring-primary-100 dark:bg-primary-500/10 dark:text-primary-200 dark:ring-primary-400/20',
'running' => 'bg-primary-100 text-primary-800 ring-1 ring-inset ring-primary-200 dark:bg-primary-500/20 dark:text-primary-100 dark:ring-primary-400/25',
'succeeded' => 'bg-success-50 text-success-800 ring-1 ring-inset ring-success-200 dark:bg-success-500/15 dark:text-success-100 dark:ring-success-400/25',
'partial', 'blocked' => 'bg-warning-50 text-warning-800 ring-1 ring-inset ring-warning-200 dark:bg-warning-500/10 dark:text-warning-100 dark:ring-warning-400/25',
default => 'bg-danger-50 text-danger-800 ring-1 ring-inset ring-danger-200 dark:bg-danger-500/10 dark:text-danger-100 dark:ring-danger-400/25',
};
$elapsedLabel = \App\Support\OpsUx\RunDurationInsights::elapsedCompact($run);
$surfaceGuidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
$activitySummaryLine = $isTerminalRun
? sprintf('Completed · %s', \App\Support\OpsUx\RunDurationInsights::completedRecency($run))
: sprintf(
'%s · %s · %s',
$run->status === 'queued' ? 'Queued' : 'Running',
$elapsedLabel,
$progressLabel ?? 'Progress details pending.',
);
@endphp
<div
class="tp-ops-activity-item min-w-0 @if (! $loop->last) border-b border-gray-200/70 pb-3 dark:border-white/10 @endif"
wire:key="run-{{ $run->id }}"
data-testid="ops-ux-activity-feedback-item"
>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
</h4>
<span
class="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap {{ $statusClasses }}"
data-testid="ops-ux-activity-feedback-status-pill"
>
{{ $statusLabel }}
</span>
@if ($showsLifecycleAttention)
<span class="inline-flex shrink-0 items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-[11px] font-medium whitespace-nowrap text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
{{ $lifecycleAttention }}
</span>
@endif
</div>
<p class="tp-ops-activity-item-meta text-xs text-gray-500 dark:text-gray-400" data-testid="ops-ux-activity-feedback-meta">
{{ $activitySummaryLine }}
</p>
@if ($isTerminalRun && $surfaceGuidance)
<p class="text-xs font-medium text-gray-700 dark:text-gray-200" data-testid="ops-ux-activity-feedback-guidance">
{{ $surfaceGuidance }}
</p>
@endif
@if ($progressPercent !== null && $progressLabel !== null)
<div class="tp-ops-activity-progress min-w-0" data-testid="ops-ux-activity-feedback-progress">
<div class="mt-1 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10" style="height: 0.375rem;" data-testid="ops-ux-activity-feedback-track">
<div
class="block h-full rounded-full bg-primary-500 dark:bg-primary-400"
role="progressbar"
aria-label="Progress"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ $progressPercent }}"
aria-valuetext="{{ $progressLabel }}"
style="width: {{ $progressPercent }}%;"
></div>
</div>
</div>
@elseif ($showsIndeterminateProgress)
<div class="tp-ops-activity-indeterminate min-w-0" data-testid="ops-ux-activity-feedback-indeterminate">
<div class="mt-1 overflow-hidden rounded-full bg-gray-100/90 dark:bg-white/8" style="height: 0.375rem;" data-testid="ops-ux-activity-feedback-track">
<div class="h-full w-[58%] rounded-full bg-primary-400/45 dark:bg-primary-300/35"></div>
</div>
</div>
@endif
</div>
</div>
@endforeach
</div>
@if ($overflowCount > 0)
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ $overflowCount }} more operation update{{ $overflowCount === 1 ? '' : 's' }} available in
@if ($operationsIndexUrl)
<a href="{{ $operationsIndexUrl }}" class="font-medium text-gray-700 underline decoration-gray-300 underline-offset-2 transition hover:text-gray-900 dark:text-gray-200 dark:decoration-white/20 dark:hover:text-white" data-testid="ops-ux-activity-feedback-overflow-link">{{ $operationsCollectionLabel }}</a>.
@else
{{ $operationsCollectionLabel }}.
@endif
</p>
@endif
</div>
</div>
<div class="w-full" x-cloak x-show="isCollapsed && $wire.hasActiveRuns === true" data-testid="ops-ux-activity-feedback-collapsed">
<div class="flex items-center justify-between gap-4 rounded-xl border border-dashed border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/5">
<div class="text-[15px] font-semibold leading-5 tracking-tight text-gray-900 dark:text-gray-100">
{{ $activeRunCount }} active operation{{ $activeRunCount === 1 ? '' : 's' }}
</div>
<div class="flex items-center gap-2">
@if($tenant)
<a
href="{{ \App\Support\OpsUx\OperationRunUrl::index($tenant) }}"
class="inline-flex items-center rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:border-gray-300 hover:text-gray-900 dark:border-white/10 dark:text-gray-200 dark:hover:border-white/20 dark:hover:text-white"
>
Show all operations
</a>
@endif
<button
type="button"
class="inline-flex items-center rounded-lg border border-transparent px-3 py-2 text-xs font-medium text-primary-700 transition hover:bg-primary-50 hover:text-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/40 dark:hover:text-primary-200"
x-on:click="expand()"
data-testid="ops-ux-activity-feedback-expand"
>
Show activity
</button>
</div>
</div>
</div>
</div>
</section>
@endif
</div>