diff --git a/app/Filament/Widgets/Dashboard/DashboardKpis.php b/app/Filament/Widgets/Dashboard/DashboardKpis.php index 6df636d..9dc3cc5 100644 --- a/app/Filament/Widgets/Dashboard/DashboardKpis.php +++ b/app/Filament/Widgets/Dashboard/DashboardKpis.php @@ -4,35 +4,46 @@ namespace App\Filament\Widgets\Dashboard; +use App\Filament\Resources\FindingResource; +use App\Filament\Resources\OperationRunResource; use App\Models\Finding; use App\Models\OperationRun; use App\Models\Tenant; use App\Support\OpsUx\ActiveRuns; use Filament\Facades\Filament; -use Filament\Widgets\Widget; +use Filament\Widgets\StatsOverviewWidget; +use Filament\Widgets\StatsOverviewWidget\Stat; -class DashboardKpis extends Widget +class DashboardKpis extends StatsOverviewWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.dashboard.dashboard-kpis'; - protected int|string|array $columnSpan = 'full'; + protected function getPollingInterval(): ?string + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return ActiveRuns::existForTenant($tenant) ? '10s' : null; + } + /** - * @return array + * @return array */ - protected function getViewData(): array + protected function getStats(): array { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return [ - 'pollingInterval' => null, - 'openDriftFindings' => 0, - 'highSeverityDriftFindings' => 0, - 'activeRuns' => 0, - 'inventoryActiveRuns' => 0, + Stat::make('Open drift findings', 0), + Stat::make('High severity drift', 0), + Stat::make('Active operations', 0), + Stat::make('Inventory active', 0), ]; } @@ -63,11 +74,17 @@ protected function getViewData(): array ->count(); return [ - 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, - 'openDriftFindings' => $openDriftFindings, - 'highSeverityDriftFindings' => $highSeverityDriftFindings, - 'activeRuns' => $activeRuns, - 'inventoryActiveRuns' => $inventoryActiveRuns, + Stat::make('Open drift findings', $openDriftFindings) + ->url(FindingResource::getUrl('index', tenant: $tenant)), + Stat::make('High severity drift', $highSeverityDriftFindings) + ->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray') + ->url(FindingResource::getUrl('index', tenant: $tenant)), + Stat::make('Active operations', $activeRuns) + ->color($activeRuns > 0 ? 'warning' : 'gray') + ->url(OperationRunResource::getUrl('index', tenant: $tenant)), + Stat::make('Inventory active', $inventoryActiveRuns) + ->color($inventoryActiveRuns > 0 ? 'warning' : 'gray') + ->url(OperationRunResource::getUrl('index', tenant: $tenant)), ]; } } diff --git a/app/Filament/Widgets/Dashboard/NeedsAttention.php b/app/Filament/Widgets/Dashboard/NeedsAttention.php index 4c7b76d..6539c1d 100644 --- a/app/Filament/Widgets/Dashboard/NeedsAttention.php +++ b/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -31,6 +31,7 @@ protected function getViewData(): array return [ 'pollingInterval' => null, 'items' => [], + 'healthyChecks' => [], ]; } @@ -50,6 +51,8 @@ protected function getViewData(): array 'title' => 'High severity drift findings', 'body' => "{$highSeverityCount} finding(s) need review.", 'url' => FindingResource::getUrl('index', tenant: $tenant), + 'badge' => 'Drift', + 'badgeColor' => 'danger', ]; } @@ -67,6 +70,8 @@ protected function getViewData(): array 'title' => 'No drift scan yet', 'body' => 'Generate drift after you have at least two successful inventory runs.', 'url' => DriftLanding::getUrl(tenant: $tenant), + 'badge' => 'Drift', + 'badgeColor' => 'warning', ]; } else { $isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true; @@ -76,6 +81,8 @@ protected function getViewData(): array 'title' => 'Drift stale', 'body' => 'Last drift scan is older than 7 days.', 'url' => DriftLanding::getUrl(tenant: $tenant), + 'badge' => 'Drift', + 'badgeColor' => 'warning', ]; } } @@ -93,6 +100,8 @@ protected function getViewData(): array 'title' => 'Drift generation failed', 'body' => 'Investigate the latest failed run.', 'url' => OperationRunLinks::view($latestDriftFailure, $tenant), + 'badge' => 'Operations', + 'badgeColor' => 'danger', ]; } @@ -106,12 +115,44 @@ protected function getViewData(): array 'title' => 'Operations in progress', 'body' => "{$activeRuns} run(s) are active.", 'url' => OperationRunLinks::index($tenant), + 'badge' => 'Operations', + 'badgeColor' => 'warning', + ]; + } + + $items = array_slice($items, 0, 5); + + $healthyChecks = []; + + if ($items === []) { + $healthyChecks = [ + [ + 'title' => 'Drift findings look healthy', + 'body' => 'No high severity drift findings are open.', + 'url' => FindingResource::getUrl('index', tenant: $tenant), + 'linkLabel' => 'View findings', + ], + [ + 'title' => 'Drift scans are up to date', + 'body' => $latestDriftSuccess?->completed_at + ? 'Last drift scan: '.$latestDriftSuccess->completed_at->diffForHumans(['short' => true]).'.' + : 'Drift scan history is available in Drift.', + 'url' => DriftLanding::getUrl(tenant: $tenant), + 'linkLabel' => 'Open Drift', + ], + [ + 'title' => 'No active operations', + 'body' => 'Nothing is currently running for this tenant.', + 'url' => OperationRunLinks::index($tenant), + 'linkLabel' => 'View operations', + ], ]; } return [ 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, 'items' => $items, + 'healthyChecks' => $healthyChecks, ]; } } diff --git a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php index 8483da0..448046a 100644 --- a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php +++ b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php @@ -4,46 +4,80 @@ namespace App\Filament\Widgets\Dashboard; +use App\Filament\Resources\FindingResource; use App\Models\Finding; +use App\Models\InventoryItem; use App\Models\Tenant; use App\Support\OpsUx\ActiveRuns; use Filament\Facades\Filament; -use Filament\Widgets\Widget; -use Illuminate\Support\Collection; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; +use Filament\Widgets\TableWidget; +use Illuminate\Database\Eloquent\Builder; -class RecentDriftFindings extends Widget +class RecentDriftFindings extends TableWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.dashboard.recent-drift-findings'; - - /** - * @return array - */ - protected function getViewData(): array + public function table(Table $table): Table { $tenant = Filament::getTenant(); - if (! $tenant instanceof Tenant) { - return [ - 'pollingInterval' => null, - 'findings' => collect(), - ]; - } + return $table + ->heading('Recent Drift Findings') + ->query($this->getQuery()) + ->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null) + ->paginated([10]) + ->columns([ + TextColumn::make('short_id') + ->label('ID') + ->state(fn (Finding $record): string => '#'.$record->getKey()) + ->copyable() + ->copyableState(fn (Finding $record): string => (string) $record->getKey()), + TextColumn::make('subject_display_name') + ->label('Subject') + ->placeholder('—') + ->limit(40) + ->tooltip(fn (Finding $record): ?string => $record->subject_display_name ?: null), + TextColumn::make('severity') + ->badge() + ->color(fn (Finding $record): string => match ($record->severity) { + Finding::SEVERITY_HIGH => 'danger', + Finding::SEVERITY_MEDIUM => 'warning', + default => 'gray', + }), + TextColumn::make('status') + ->badge() + ->color(fn (Finding $record): string => $record->status === Finding::STATUS_NEW ? 'warning' : 'gray'), + TextColumn::make('created_at') + ->label('Created') + ->since(), + ]) + ->recordUrl(fn (Finding $record): ?string => $tenant instanceof Tenant + ? FindingResource::getUrl('view', ['record' => $record], tenant: $tenant) + : null) + ->emptyStateHeading('No drift findings') + ->emptyStateDescription('You\'re looking good — no drift findings to review yet.'); + } - $tenantId = (int) $tenant->getKey(); + /** + * @return Builder + */ + private function getQuery(): Builder + { + $tenant = Filament::getTenant(); + $tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null; - /** @var Collection $findings */ - $findings = Finding::query() - ->where('tenant_id', $tenantId) + return Finding::query() + ->addSelect([ + 'subject_display_name' => InventoryItem::query() + ->select('display_name') + ->whereColumn('inventory_items.tenant_id', 'findings.tenant_id') + ->whereColumn('inventory_items.external_id', 'findings.subject_external_id') + ->limit(1), + ]) + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) ->where('finding_type', Finding::FINDING_TYPE_DRIFT) - ->latest('created_at') - ->limit(10) - ->get(); - - return [ - 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, - 'findings' => $findings, - ]; + ->latest('created_at'); } } diff --git a/app/Filament/Widgets/Dashboard/RecentOperations.php b/app/Filament/Widgets/Dashboard/RecentOperations.php index a5640b6..9c9428e 100644 --- a/app/Filament/Widgets/Dashboard/RecentOperations.php +++ b/app/Filament/Widgets/Dashboard/RecentOperations.php @@ -10,48 +10,85 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\ActiveRuns; use Filament\Facades\Filament; -use Filament\Widgets\Widget; -use Illuminate\Support\Collection; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; +use Filament\Widgets\TableWidget; +use Illuminate\Database\Eloquent\Builder; -class RecentOperations extends Widget +class RecentOperations extends TableWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.dashboard.recent-operations'; - protected int|string|array $columnSpan = 'full'; - /** - * @return array - */ - protected function getViewData(): array + public function table(Table $table): Table { $tenant = Filament::getTenant(); - if (! $tenant instanceof Tenant) { - return [ - 'pollingInterval' => null, - 'runs' => collect(), - 'viewRunBaseUrl' => null, - ]; - } + return $table + ->heading('Recent Operations') + ->query($this->getQuery()) + ->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null) + ->paginated([10]) + ->columns([ + TextColumn::make('short_id') + ->label('Run') + ->state(fn (OperationRun $record): string => '#'.$record->getKey()) + ->copyable() + ->copyableState(fn (OperationRun $record): string => (string) $record->getKey()), + TextColumn::make('type') + ->label('Operation') + ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) + ->limit(40) + ->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)), + TextColumn::make('status') + ->badge() + ->color(fn (OperationRun $record): string => $this->statusColor($record->status)), + TextColumn::make('outcome') + ->badge() + ->color(fn (OperationRun $record): string => $this->outcomeColor($record->outcome)), + TextColumn::make('created_at') + ->label('Started') + ->since(), + ]) + ->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant + ? OperationRunLinks::view($record, $tenant) + : null) + ->emptyStateHeading('No operations yet') + ->emptyStateDescription('Once you run inventory sync, drift generation, or restores, they\'ll show up here.'); + } - $tenantId = (int) $tenant->getKey(); + /** + * @return Builder + */ + private function getQuery(): Builder + { + $tenant = Filament::getTenant(); + $tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null; - /** @var Collection $runs */ - $runs = OperationRun::query() - ->where('tenant_id', $tenantId) - ->latest('created_at') - ->limit(10) - ->get() - ->each(function (OperationRun $run) use ($tenant): void { - $run->setAttribute('type_label', OperationCatalog::label((string) $run->type)); - $run->setAttribute('view_url', OperationRunLinks::view($run, $tenant)); - }); + return OperationRun::query() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->latest('created_at'); + } - return [ - 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, - 'runs' => $runs, - ]; + private function statusColor(?string $status): string + { + return match ($status) { + 'queued' => 'secondary', + 'running' => 'warning', + 'completed' => 'success', + default => 'gray', + }; + } + + private function outcomeColor(?string $outcome): string + { + return match ($outcome) { + 'succeeded' => 'success', + 'partially_succeeded' => 'warning', + 'failed' => 'danger', + 'cancelled' => 'gray', + default => 'gray', + }; } } diff --git a/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php b/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php deleted file mode 100644 index da6c92a..0000000 --- a/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -
-
-
-
Open drift findings
-
{{ $openDriftFindings }}
-
- -
-
High severity drift
-
{{ $highSeverityDriftFindings }}
-
- -
-
Active operations
-
{{ $activeRuns }}
-
- -
-
Inventory active
-
{{ $inventoryActiveRuns }}
-
-
-
diff --git a/resources/views/filament/widgets/dashboard/needs-attention.blade.php b/resources/views/filament/widgets/dashboard/needs-attention.blade.php index e450088..de0026b 100644 --- a/resources/views/filament/widgets/dashboard/needs-attention.blade.php +++ b/resources/views/filament/widgets/dashboard/needs-attention.blade.php @@ -2,25 +2,54 @@ @if ($pollingInterval) wire:poll.{{ $pollingInterval }} @endif - class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10" + class="flex flex-col gap-4" > -
-
Needs Attention
+
Needs Attention
+ + @if (count($items) === 0) +
+
+ Everything looks healthy right now. +
- @if (count($items) === 0) -
Nothing urgent right now.
- @else
- @foreach ($items as $item) - -
{{ $item['title'] }}
-
{{ $item['body'] }}
-
+ @foreach ($healthyChecks as $check) +
+ + +
+
{{ $check['title'] }}
+
{{ $check['body'] }}
+ +
+ + {{ $check['linkLabel'] }} + +
+
+
@endforeach
- @endif -
+
+ @else + + @endif diff --git a/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php b/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php deleted file mode 100644 index ca3b325..0000000 --- a/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php +++ /dev/null @@ -1,34 +0,0 @@ -
-
-
Recent Drift Findings
-
- -
- @if ($findings->isEmpty()) -
No drift findings yet.
- @else -
- @foreach ($findings as $finding) -
-
-
- {{ $finding->subject_type }} · {{ $finding->subject_external_id }} -
-
- {{ $finding->created_at?->diffForHumans() }} -
-
-
- Severity: {{ $finding->severity }} · Status: {{ $finding->status }} -
-
- @endforeach -
- @endif -
-
diff --git a/resources/views/filament/widgets/dashboard/recent-operations.blade.php b/resources/views/filament/widgets/dashboard/recent-operations.blade.php deleted file mode 100644 index 8aaa690..0000000 --- a/resources/views/filament/widgets/dashboard/recent-operations.blade.php +++ /dev/null @@ -1,37 +0,0 @@ -
-
-
Recent Operations
-
- - -
diff --git a/tests/Feature/Filament/TenantDashboardTenantScopeTest.php b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php index 9f6310c..1d61da5 100644 --- a/tests/Feature/Filament/TenantDashboardTenantScopeTest.php +++ b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php @@ -4,6 +4,7 @@ use App\Filament\Pages\TenantDashboard; use App\Models\Finding; +use App\Models\InventoryItem; use App\Models\OperationRun; use App\Models\Tenant; @@ -11,6 +12,12 @@ [$user, $tenant] = createUserWithTenant(role: 'owner'); $otherTenant = Tenant::factory()->create(); + InventoryItem::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'external_id' => 'other-tenant-finding', + 'display_name' => 'Other Tenant Policy', + ]); + Finding::factory()->create([ 'tenant_id' => $otherTenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, @@ -29,5 +36,5 @@ $this->get(TenantDashboard::getUrl(tenant: $tenant)) ->assertOk() - ->assertDontSee('other-tenant-finding'); + ->assertDontSee('Other Tenant Policy'); });