256 lines
9.6 KiB
PHP
256 lines
9.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\System\Pages\Ops;
|
|
|
|
use App\Models\PlatformUser;
|
|
use App\Models\Tenant;
|
|
use App\Services\Auth\BreakGlassSession;
|
|
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
|
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
|
use App\Services\Runbooks\RunbookReason;
|
|
use App\Services\System\AllowedTenantUniverse;
|
|
use App\Support\Auth\PlatformCapabilities;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\System\SystemOperationRunLinks;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\Radio;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class Runbooks extends Page
|
|
{
|
|
protected static ?string $navigationLabel = 'Runbooks';
|
|
|
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
|
|
|
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
|
|
|
protected static ?string $slug = 'ops/runbooks';
|
|
|
|
protected string $view = 'filament.system.pages.ops.runbooks';
|
|
|
|
public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
|
|
|
|
public ?int $tenantId = null;
|
|
|
|
/**
|
|
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
|
|
*/
|
|
public ?array $preflight = null;
|
|
|
|
public function scopeLabel(): string
|
|
{
|
|
if ($this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) {
|
|
return 'All tenants';
|
|
}
|
|
|
|
$tenantName = $this->selectedTenantName();
|
|
|
|
if ($tenantName !== null) {
|
|
return "Single tenant ({$tenantName})";
|
|
}
|
|
|
|
return $this->tenantId !== null ? "Single tenant (#{$this->tenantId})" : 'Single tenant';
|
|
}
|
|
|
|
public function selectedTenantName(): ?string
|
|
{
|
|
if ($this->tenantId === null) {
|
|
return null;
|
|
}
|
|
|
|
return Tenant::query()->whereKey($this->tenantId)->value('name');
|
|
}
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
$user = auth('platform')->user();
|
|
|
|
if (! $user instanceof PlatformUser) {
|
|
return false;
|
|
}
|
|
|
|
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
|
|
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
|
|
}
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
Action::make('preflight')
|
|
->label('Preflight')
|
|
->color('gray')
|
|
->icon('heroicon-o-magnifying-glass')
|
|
->form($this->scopeForm())
|
|
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
|
$scope = FindingsLifecycleBackfillScope::fromArray([
|
|
'mode' => $data['scope_mode'] ?? null,
|
|
'tenant_id' => $data['tenant_id'] ?? null,
|
|
]);
|
|
|
|
$this->scopeMode = $scope->mode;
|
|
$this->tenantId = $scope->tenantId;
|
|
|
|
$this->preflight = $runbookService->preflight($scope);
|
|
|
|
Notification::make()
|
|
->title('Preflight complete')
|
|
->success()
|
|
->send();
|
|
}),
|
|
|
|
Action::make('run')
|
|
->label('Run…')
|
|
->icon('heroicon-o-play')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->modalHeading('Run: Rebuild Findings Lifecycle')
|
|
->modalDescription('This operation may modify customer data. Review the preflight and confirm before running.')
|
|
->form($this->runForm())
|
|
->disabled(fn (): bool => ! is_array($this->preflight) || (int) ($this->preflight['affected_count'] ?? 0) <= 0)
|
|
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
|
if (! is_array($this->preflight) || (int) ($this->preflight['affected_count'] ?? 0) <= 0) {
|
|
throw ValidationException::withMessages([
|
|
'preflight' => 'Run preflight first.',
|
|
]);
|
|
}
|
|
|
|
$scope = $this->scopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT
|
|
? FindingsLifecycleBackfillScope::singleTenant((int) $this->tenantId)
|
|
: FindingsLifecycleBackfillScope::allTenants();
|
|
|
|
$user = auth('platform')->user();
|
|
|
|
if (! $user instanceof PlatformUser) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $user->hasCapability(PlatformCapabilities::RUNBOOKS_RUN)
|
|
|| ! $user->hasCapability(PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL)
|
|
) {
|
|
abort(403);
|
|
}
|
|
|
|
if ($scope->isAllTenants()) {
|
|
$typedConfirmation = (string) ($data['typed_confirmation'] ?? '');
|
|
|
|
if ($typedConfirmation !== 'BACKFILL') {
|
|
throw ValidationException::withMessages([
|
|
'typed_confirmation' => 'Please type BACKFILL to confirm.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
$reason = RunbookReason::fromNullableArray([
|
|
'reason_code' => $data['reason_code'] ?? null,
|
|
'reason_text' => $data['reason_text'] ?? null,
|
|
]);
|
|
|
|
$run = $runbookService->start(
|
|
scope: $scope,
|
|
initiator: $user,
|
|
reason: $reason,
|
|
source: 'system_ui',
|
|
);
|
|
|
|
$viewUrl = SystemOperationRunLinks::view($run);
|
|
|
|
$toast = $run->wasRecentlyCreated
|
|
? OperationUxPresenter::queuedToast((string) $run->type)->body('The runbook will execute in the background.')
|
|
: OperationUxPresenter::alreadyQueuedToast((string) $run->type);
|
|
|
|
$toast
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url($viewUrl),
|
|
])
|
|
->send();
|
|
}),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, \Filament\Schemas\Components\Component>
|
|
*/
|
|
private function scopeForm(): array
|
|
{
|
|
return [
|
|
Radio::make('scope_mode')
|
|
->label('Scope')
|
|
->options([
|
|
FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants',
|
|
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
|
|
])
|
|
->default($this->scopeMode)
|
|
->required(),
|
|
|
|
Select::make('tenant_id')
|
|
->label('Tenant')
|
|
->searchable()
|
|
->visible(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
|
->required(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
|
|
->getSearchResultsUsing(function (string $search, AllowedTenantUniverse $universe): array {
|
|
return $universe
|
|
->query()
|
|
->where('name', 'like', "%{$search}%")
|
|
->orderBy('name')
|
|
->limit(25)
|
|
->pluck('name', 'id')
|
|
->all();
|
|
})
|
|
->getOptionLabelUsing(function ($value, AllowedTenantUniverse $universe): ?string {
|
|
if (! is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
return $universe
|
|
->query()
|
|
->whereKey((int) $value)
|
|
->value('name');
|
|
}),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, \Filament\Schemas\Components\Component>
|
|
*/
|
|
private function runForm(): array
|
|
{
|
|
return [
|
|
TextInput::make('typed_confirmation')
|
|
->label('Type BACKFILL to confirm')
|
|
->visible(fn (): bool => $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
|
->required(fn (): bool => $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
|
|
->in(['BACKFILL'])
|
|
->validationMessages([
|
|
'in' => 'Please type BACKFILL to confirm.',
|
|
]),
|
|
|
|
Select::make('reason_code')
|
|
->label('Reason code')
|
|
->options(RunbookReason::options())
|
|
->required(function (BreakGlassSession $breakGlass): bool {
|
|
return $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
|
|
}),
|
|
|
|
Textarea::make('reason_text')
|
|
->label('Reason')
|
|
->rows(4)
|
|
->maxLength(500)
|
|
->required(function (BreakGlassSession $breakGlass): bool {
|
|
return $this->scopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
|
|
}),
|
|
];
|
|
}
|
|
}
|