TenantAtlas/app/Filament/System/Pages/Ops/Failures.php
ahmido fdd3a85b64 feat: align system operations surfaces (#201)
## Summary
- align the system-panel Operations, Failed operations, and Stuck operations pages to the read-only registry contract by removing inline row triage and keeping row-click inspection
- keep retry, cancel, and mark-investigated behavior on the canonical system operation detail page while adding the explicit `Show all operations` return path and updated `Operations / Operation` copy
- add and update focused Pest and Livewire coverage for list CTA behavior, detail-owned triage, and view-only versus manage-capable platform access
- add Spec 170 implementation artifacts plus the follow-on Spec 171 and Spec 172 packages

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsTriageActionsTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsFailuresViewTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/OpsStuckViewTest.php`
- integrated browser smoke on `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck`, empty states via search filter, and detail-page retry confirmation visibility

## Notes
- branch pushed from `170-system-operations-surface-alignment`
- latest commit: `64b4d741 feat: align system operations surfaces`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #201
2026-03-30 19:08:56 +00:00

152 lines
6.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Ops;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\System\SystemOperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class Failures extends Page implements HasTable
{
use InteractsWithTable;
protected static ?string $navigationLabel = 'Failed operations';
protected static ?string $title = 'Failed operations';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
protected static ?string $slug = 'ops/failures';
protected string $view = 'filament.system.pages.ops.failures';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'The page header exposes Show all operations while row clicks remain the only inspect model.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Failed operations remain scan-first and intentionally omit bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when there are no failed operations and repeats the Show all operations CTA.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
}
public static function getNavigationBadge(): ?string
{
$count = OperationRun::query()
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)
->count();
return $count > 0 ? (string) $count : null;
}
public static function getNavigationBadgeColor(): string|array|null
{
return 'danger';
}
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('show_all_operations')
->label('Show all operations')
->url(SystemOperationRunLinks::index()),
];
}
public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->query(function (): Builder {
return OperationRun::query()
->with(['tenant', 'workspace'])
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value);
})
->columns([
TextColumn::make('id')
->label('ID')
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextColumn::make('type')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->searchable(),
TextColumn::make('workspace.name')
->label('Workspace')
->toggleable(),
TextColumn::make('tenant.name')
->label('Tenant')
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
->toggleable(),
TextColumn::make('created_at')->label('Started')->since(),
])
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
->actions([])
->emptyStateHeading('No failed operations found')
->emptyStateDescription('Failed operations will appear here when a run completes unsuccessfully.')
->emptyStateActions([
Action::make('show_all_operations_empty')
->label('Show all operations')
->url(SystemOperationRunLinks::index())
->button(),
])
->bulkActions([]);
}
}