TenantAtlas/app/Filament/Resources/OperationRunResource.php
ahmido bd6df1f343 055-ops-ux-rollout (#64)
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
2026-01-18 14:50:15 +00:00

277 lines
11 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\OperationRunResource\Pages;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\OpsUx\RunDurationInsights;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class OperationRunResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = OperationRun::class;
protected static ?string $slug = 'operations';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Operations';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Run')
->schema([
TextEntry::make('type')
->badge()
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)),
TextEntry::make('status')
->badge()
->color(fn (OperationRun $record): string => static::statusColor($record->status)),
TextEntry::make('outcome')
->badge()
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
TextEntry::make('initiator_name')->label('Initiator'),
TextEntry::make('elapsed')
->label('Elapsed')
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
TextEntry::make('expected_duration')
->label('Expected')
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::expectedHuman($record) ?? '—'),
TextEntry::make('stuck_guidance')
->label('')
->getStateUsing(fn (OperationRun $record): ?string => RunDurationInsights::stuckGuidance($record))
->visible(fn (OperationRun $record): bool => RunDurationInsights::stuckGuidance($record) !== null),
TextEntry::make('created_at')->dateTime(),
TextEntry::make('started_at')->dateTime()->placeholder('—'),
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
TextEntry::make('run_identity_hash')->label('Identity hash')->copyable(),
])
->extraAttributes([
'x-init' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
'x-on:visibilitychange.window' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
])
->poll(function (OperationRun $record, $livewire): ?string {
if (($livewire->opsUxIsTabHidden ?? false) === true) {
return null;
}
if (filled($livewire->mountedActions ?? null)) {
return null;
}
return RunDetailPolling::interval($record);
})
->columns(2)
->columnSpanFull(),
Section::make('Counts')
->schema([
ViewEntry::make('summary_counts')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->summary_counts ?? [])
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => ! empty($record->summary_counts))
->columnSpanFull(),
Section::make('Failures')
->schema([
ViewEntry::make('failure_summary')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->failure_summary ?? [])
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
->columnSpanFull(),
Section::make('Context')
->schema([
ViewEntry::make('context')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (OperationRun $record): array => $record->context ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (OperationRun $record): string => static::statusColor($record->status)),
Tables\Columns\TextColumn::make('type')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('initiator_name')
->label('Initiator')
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->label('Started')
->since()
->sortable(),
Tables\Columns\TextColumn::make('duration')
->getStateUsing(function (OperationRun $record): string {
if ($record->started_at && $record->completed_at) {
return $record->completed_at->diffForHumans($record->started_at, true);
}
return '—';
}),
Tables\Columns\TextColumn::make('outcome')
->badge()
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
])
->filters([
Tables\Filters\SelectFilter::make('type')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
if (! $tenantId) {
return [];
}
return OperationRun::query()
->where('tenant_id', $tenantId)
->select('type')
->distinct()
->orderBy('type')
->pluck('type', 'type')
->all();
}),
Tables\Filters\SelectFilter::make('status')
->options([
OperationRunStatus::Queued->value => 'Queued',
OperationRunStatus::Running->value => 'Running',
OperationRunStatus::Completed->value => 'Completed',
]),
Tables\Filters\SelectFilter::make('outcome')
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
Tables\Filters\SelectFilter::make('initiator_name')
->label('Initiator')
->options(function (): array {
$tenantId = Tenant::current()?->getKey();
if (! $tenantId) {
return [];
}
return OperationRun::query()
->where('tenant_id', $tenantId)
->whereNotNull('initiator_name')
->select('initiator_name')
->distinct()
->orderBy('initiator_name')
->pluck('initiator_name', 'initiator_name')
->all();
})
->searchable(),
Tables\Filters\Filter::make('created_at')
->label('Created')
->form([
DatePicker::make('created_from')
->label('From'),
DatePicker::make('created_until')
->label('Until'),
])
->default(fn (): array => [
'created_from' => now()->subDays(30),
'created_until' => now(),
])
->query(function (Builder $query, array $data): Builder {
$from = $data['created_from'] ?? null;
if ($from) {
$query->whereDate('created_at', '>=', $from);
}
$until = $data['created_until'] ?? null;
if ($until) {
$query->whereDate('created_at', '<=', $until);
}
return $query;
}),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with('user')
->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListOperationRuns::route('/'),
'view' => Pages\ViewOperationRun::route('/{record}'),
];
}
private static function statusColor(?string $status): string
{
return match ($status) {
'queued' => 'secondary',
'running' => 'warning',
'completed' => 'success',
default => 'gray',
};
}
private static function outcomeColor(?string $outcome): string
{
return match ($outcome) {
'succeeded' => 'success',
'partially_succeeded' => 'warning',
'failed' => 'danger',
'cancelled' => 'gray',
default => 'gray',
};
}
}