## Summary
Implements and polishes the Platform Ops Runbooks feature (Spec 113) — the operator control plane for safe backfills and data repair from `/system`.
## Changes
### UX Polish (Phase 7 — US4)
- **Filament-native components**: Rewrote `runbooks.blade.php` and `view-run.blade.php` using `<x-filament::section>` instead of raw Tailwind div cards. Cards now render correctly with Filament's built-in borders, shadows and dark mode.
- **System panel theme**: Created `resources/css/filament/system/theme.css` and registered `->viteTheme()` on `SystemPanelProvider`. The system panel previously had no theme CSS registered — Tailwind utility classes weren't compiled for its views, causing the warning icon SVG to expand to full container size.
- **Live scope selector**: Added `->live()` to the scope `Radio` field so "Single tenant" immediately reveals the tenant search dropdown without requiring a Submit first.
### Core Feature (Phases 1–6, previously shipped)
- `/system/ops/runbooks` — runbook catalog, preflight, run with typed confirmation + reason
- `/system/ops/runs` — run history table with status/outcome badges
- `/system/ops/runs/{id}` — run detail view with summary counts, failures, collapsible context
- `FindingsLifecycleBackfillRunbookService` — preflight + execution logic
- AllowedTenantUniverse — scopes tenant picker to non-platform tenants only
- RBAC: `platform.ops.view`, `platform.runbooks.view`, `platform.runbooks.run`, `platform.runbooks.findings.lifecycle_backfill`
- Rate-limited `/system/login` (10/min per IP+username)
- Distinct session cookie for `/system` isolation
## Test Coverage
- 16 tests / 141 assertions — all passing
- Covers: page access, RBAC, preflight, run dispatch, scope selector, run detail, run list
## Checklist
- [x] Filament v5 / Livewire v4 compliant
- [x] Provider registered in `bootstrap/providers.php`
- [x] Destructive actions require confirmation (`->requiresConfirmation()`)
- [x] System panel theme registered (`viteTheme`)
- [x] Pint clean
- [x] Tests pass
Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #137
325 lines
12 KiB
PHP
325 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources\FindingResource\Pages;
|
|
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Jobs\BackfillFindingLifecycleJob;
|
|
use App\Models\Finding;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Findings\FindingWorkflowService;
|
|
use App\Services\OperationRunService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\Rbac\UiTooltips;
|
|
use Filament\Actions;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Pages\ListRecords;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Arr;
|
|
use Throwable;
|
|
|
|
class ListFindings extends ListRecords
|
|
{
|
|
protected static string $resource = FindingResource::class;
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$actions = [];
|
|
|
|
if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) {
|
|
$actions[] = UiEnforcement::forAction(
|
|
Actions\Action::make('backfill_lifecycle')
|
|
->label('Backfill findings lifecycle')
|
|
->icon('heroicon-o-wrench-screwdriver')
|
|
->color('gray')
|
|
->requiresConfirmation()
|
|
->modalHeading('Backfill findings lifecycle')
|
|
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
|
|
->action(function (OperationRunService $operationRuns): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$tenant = \Filament\Facades\Filament::getTenant();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$opRun = $operationRuns->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: 'findings.lifecycle.backfill',
|
|
identityInputs: [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'trigger' => 'backfill',
|
|
],
|
|
context: [
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'initiator_user_id' => (int) $user->getKey(),
|
|
],
|
|
initiator: $user,
|
|
);
|
|
|
|
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
|
|
|
if ($opRun->wasRecentlyCreated === false) {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url($runUrl),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
|
|
BackfillFindingLifecycleJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
workspaceId: (int) $tenant->workspace_id,
|
|
initiatorUserId: (int) $user->getKey(),
|
|
);
|
|
});
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
->body('The backfill will run in the background. You can continue working while it completes.')
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url($runUrl),
|
|
])
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply();
|
|
}
|
|
|
|
$actions[] = UiEnforcement::forAction(
|
|
Actions\Action::make('triage_all_matching')
|
|
->label('Triage all matching')
|
|
->icon('heroicon-o-check')
|
|
->color('gray')
|
|
->requiresConfirmation()
|
|
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
|
->modalDescription(function (): string {
|
|
$count = $this->getAllMatchingCount();
|
|
|
|
return "You are about to triage {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
|
})
|
|
->form(function (): array {
|
|
$count = $this->getAllMatchingCount();
|
|
|
|
if ($count <= 100) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
TextInput::make('confirmation')
|
|
->label('Type TRIAGE to confirm')
|
|
->required()
|
|
->in(['TRIAGE'])
|
|
->validationMessages([
|
|
'in' => 'Please type TRIAGE to confirm.',
|
|
]),
|
|
];
|
|
})
|
|
->action(function (FindingWorkflowService $workflow): void {
|
|
$query = $this->buildAllMatchingQuery();
|
|
$count = (clone $query)->count();
|
|
|
|
if ($count === 0) {
|
|
Notification::make()
|
|
->title('No matching findings')
|
|
->body('There are no new findings matching the current filters to triage.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$user = auth()->user();
|
|
$tenant = \Filament\Facades\Filament::getTenant();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$triagedCount = 0;
|
|
$skippedCount = 0;
|
|
$failedCount = 0;
|
|
|
|
$query->orderBy('id')->chunkById(200, function ($findings) use ($workflow, $tenant, $user, &$triagedCount, &$skippedCount, &$failedCount): void {
|
|
foreach ($findings as $finding) {
|
|
if (! $finding instanceof Finding) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! in_array((string) $finding->status, [
|
|
Finding::STATUS_NEW,
|
|
Finding::STATUS_REOPENED,
|
|
Finding::STATUS_ACKNOWLEDGED,
|
|
], true)) {
|
|
$skippedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$workflow->triage($finding, $tenant, $user);
|
|
$triagedCount++;
|
|
} catch (Throwable) {
|
|
$failedCount++;
|
|
}
|
|
}
|
|
});
|
|
|
|
$this->deselectAllTableRecords();
|
|
$this->resetPage();
|
|
|
|
$body = "Triaged {$triagedCount} finding".($triagedCount === 1 ? '' : 's').'.';
|
|
if ($skippedCount > 0) {
|
|
$body .= " Skipped {$skippedCount}.";
|
|
}
|
|
if ($failedCount > 0) {
|
|
$body .= " Failed {$failedCount}.";
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Bulk triage completed')
|
|
->body($body)
|
|
->status($failedCount > 0 ? 'warning' : 'success')
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::TENANT_FINDINGS_TRIAGE)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply();
|
|
|
|
return $actions;
|
|
}
|
|
|
|
protected function buildAllMatchingQuery(): Builder
|
|
{
|
|
$query = Finding::query();
|
|
|
|
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
|
|
|
|
if (! is_numeric($tenantId)) {
|
|
return $query->whereRaw('1 = 0');
|
|
}
|
|
|
|
$query->where('tenant_id', (int) $tenantId);
|
|
|
|
$query->where('status', Finding::STATUS_NEW);
|
|
|
|
$findingType = $this->getFindingTypeFilterValue();
|
|
if (is_string($findingType) && $findingType !== '') {
|
|
$query->where('finding_type', $findingType);
|
|
}
|
|
|
|
if ($this->filterIsActive('overdue')) {
|
|
$query->whereNotNull('due_at')->where('due_at', '<', now());
|
|
}
|
|
|
|
if ($this->filterIsActive('high_severity')) {
|
|
$query->whereIn('severity', [
|
|
Finding::SEVERITY_HIGH,
|
|
Finding::SEVERITY_CRITICAL,
|
|
]);
|
|
}
|
|
|
|
if ($this->filterIsActive('my_assigned')) {
|
|
$userId = auth()->id();
|
|
|
|
if (is_numeric($userId)) {
|
|
$query->where('assignee_user_id', (int) $userId);
|
|
} else {
|
|
$query->whereRaw('1 = 0');
|
|
}
|
|
}
|
|
|
|
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
|
|
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
|
|
if (is_string($scopeKey) && $scopeKey !== '') {
|
|
$query->where('scope_key', $scopeKey);
|
|
}
|
|
|
|
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
|
$baselineRunId = Arr::get($runIdsState, 'baseline_operation_run_id');
|
|
if (is_numeric($baselineRunId)) {
|
|
$query->where('baseline_operation_run_id', (int) $baselineRunId);
|
|
}
|
|
|
|
$currentRunId = Arr::get($runIdsState, 'current_operation_run_id');
|
|
if (is_numeric($currentRunId)) {
|
|
$query->where('current_operation_run_id', (int) $currentRunId);
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
private function filterIsActive(string $filterName): bool
|
|
{
|
|
$state = $this->getTableFilterState($filterName);
|
|
|
|
if ($state === true) {
|
|
return true;
|
|
}
|
|
|
|
if (is_array($state)) {
|
|
$isActive = Arr::get($state, 'isActive');
|
|
|
|
return $isActive === true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function getAllMatchingCount(): int
|
|
{
|
|
return (int) $this->buildAllMatchingQuery()->count();
|
|
}
|
|
|
|
protected function getStatusFilterValue(): string
|
|
{
|
|
$state = $this->getTableFilterState('status') ?? [];
|
|
$value = Arr::get($state, 'value');
|
|
|
|
return is_string($value) && $value !== ''
|
|
? $value
|
|
: Finding::STATUS_NEW;
|
|
}
|
|
|
|
protected function getFindingTypeFilterValue(): ?string
|
|
{
|
|
$state = $this->getTableFilterState('finding_type') ?? [];
|
|
$value = Arr::get($state, 'value');
|
|
|
|
return is_string($value) && $value !== ''
|
|
? $value
|
|
: null;
|
|
}
|
|
}
|