252 lines
18 KiB
PHP
252 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 && ! $hasTerminalVisibleRuns ? '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.',
|
|
$hasTerminalVisibleRuns => 'Successful operation updates stay briefly visible so you can confirm completion and keep working.',
|
|
default => 'Recent operation updates.',
|
|
};
|
|
$primaryActionUrl = null;
|
|
|
|
if ($usesCollectivePrimaryAction) {
|
|
$primaryActionUrl = $operationsIndexUrl;
|
|
} elseif ($tenant && $primaryRun) {
|
|
$primaryActionUrl = \App\Support\OpsUx\OperationRunUrl::view($primaryRun, $tenant);
|
|
}
|
|
|
|
$tertiaryActionLabel = $hasTerminalFollowUpVisibleRuns
|
|
? 'Acknowledge'
|
|
: ($hasActiveVisibleRuns ? 'Hide activity' : '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>
|