From 4ad3d4a7dd9735ecb7f08a18690fbf300618cd4a Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 21 Jan 2026 14:41:46 +0100 Subject: [PATCH] feat(058): tenant dashboard + active-runs gating --- app/Filament/Pages/TenantDashboard.php | 34 +++++ .../Widgets/Dashboard/DashboardKpis.php | 73 +++++++++++ .../Widgets/Dashboard/NeedsAttention.php | 117 ++++++++++++++++++ .../Widgets/Dashboard/RecentDriftFindings.php | 49 ++++++++ .../Widgets/Dashboard/RecentOperations.php | 57 +++++++++ app/Providers/Filament/AdminPanelProvider.php | 4 +- app/Support/OpsUx/ActiveRuns.php | 19 +++ .../dashboard/dashboard-kpis.blade.php | 28 +++++ .../dashboard/needs-attention.blade.php | 26 ++++ .../dashboard/recent-drift-findings.blade.php | 34 +++++ .../dashboard/recent-operations.blade.php | 37 ++++++ .../Filament/TenantDashboardDbOnlyTest.php | 41 ++++++ .../TenantDashboardTenantScopeTest.php | 33 +++++ tests/Feature/OpsUx/ActiveRunsTest.php | 64 ++++++++++ 14 files changed, 614 insertions(+), 2 deletions(-) create mode 100644 app/Filament/Pages/TenantDashboard.php create mode 100644 app/Filament/Widgets/Dashboard/DashboardKpis.php create mode 100644 app/Filament/Widgets/Dashboard/NeedsAttention.php create mode 100644 app/Filament/Widgets/Dashboard/RecentDriftFindings.php create mode 100644 app/Filament/Widgets/Dashboard/RecentOperations.php create mode 100644 app/Support/OpsUx/ActiveRuns.php create mode 100644 resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php create mode 100644 resources/views/filament/widgets/dashboard/needs-attention.blade.php create mode 100644 resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php create mode 100644 resources/views/filament/widgets/dashboard/recent-operations.blade.php create mode 100644 tests/Feature/Filament/TenantDashboardDbOnlyTest.php create mode 100644 tests/Feature/Filament/TenantDashboardTenantScopeTest.php create mode 100644 tests/Feature/OpsUx/ActiveRunsTest.php diff --git a/app/Filament/Pages/TenantDashboard.php b/app/Filament/Pages/TenantDashboard.php new file mode 100644 index 0000000..53bf2d9 --- /dev/null +++ b/app/Filament/Pages/TenantDashboard.php @@ -0,0 +1,34 @@ + | WidgetConfiguration> + */ + public function getWidgets(): array + { + return [ + DashboardKpis::class, + NeedsAttention::class, + RecentDriftFindings::class, + RecentOperations::class, + ]; + } + + public function getColumns(): int|array + { + return 2; + } +} diff --git a/app/Filament/Widgets/Dashboard/DashboardKpis.php b/app/Filament/Widgets/Dashboard/DashboardKpis.php new file mode 100644 index 0000000..6df636d --- /dev/null +++ b/app/Filament/Widgets/Dashboard/DashboardKpis.php @@ -0,0 +1,73 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'openDriftFindings' => 0, + 'highSeverityDriftFindings' => 0, + 'activeRuns' => 0, + 'inventoryActiveRuns' => 0, + ]; + } + + $tenantId = (int) $tenant->getKey(); + + $openDriftFindings = (int) Finding::query() + ->where('tenant_id', $tenantId) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('status', Finding::STATUS_NEW) + ->count(); + + $highSeverityDriftFindings = (int) Finding::query() + ->where('tenant_id', $tenantId) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('status', Finding::STATUS_NEW) + ->where('severity', Finding::SEVERITY_HIGH) + ->count(); + + $activeRuns = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->active() + ->count(); + + $inventoryActiveRuns = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('type', 'inventory.sync') + ->active() + ->count(); + + return [ + 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, + 'openDriftFindings' => $openDriftFindings, + 'highSeverityDriftFindings' => $highSeverityDriftFindings, + 'activeRuns' => $activeRuns, + 'inventoryActiveRuns' => $inventoryActiveRuns, + ]; + } +} diff --git a/app/Filament/Widgets/Dashboard/NeedsAttention.php b/app/Filament/Widgets/Dashboard/NeedsAttention.php new file mode 100644 index 0000000..4c7b76d --- /dev/null +++ b/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -0,0 +1,117 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'items' => [], + ]; + } + + $tenantId = (int) $tenant->getKey(); + + $items = []; + + $highSeverityCount = (int) Finding::query() + ->where('tenant_id', $tenantId) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('status', Finding::STATUS_NEW) + ->where('severity', Finding::SEVERITY_HIGH) + ->count(); + + if ($highSeverityCount > 0) { + $items[] = [ + 'title' => 'High severity drift findings', + 'body' => "{$highSeverityCount} finding(s) need review.", + 'url' => FindingResource::getUrl('index', tenant: $tenant), + ]; + } + + $latestDriftSuccess = OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('type', 'drift.generate') + ->where('status', 'completed') + ->where('outcome', 'succeeded') + ->whereNotNull('completed_at') + ->latest('completed_at') + ->first(); + + if (! $latestDriftSuccess) { + $items[] = [ + 'title' => 'No drift scan yet', + 'body' => 'Generate drift after you have at least two successful inventory runs.', + 'url' => DriftLanding::getUrl(tenant: $tenant), + ]; + } else { + $isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true; + + if ($isStale) { + $items[] = [ + 'title' => 'Drift stale', + 'body' => 'Last drift scan is older than 7 days.', + 'url' => DriftLanding::getUrl(tenant: $tenant), + ]; + } + } + + $latestDriftFailure = OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('type', 'drift.generate') + ->where('status', 'completed') + ->where('outcome', 'failed') + ->latest('id') + ->first(); + + if ($latestDriftFailure instanceof OperationRun) { + $items[] = [ + 'title' => 'Drift generation failed', + 'body' => 'Investigate the latest failed run.', + 'url' => OperationRunLinks::view($latestDriftFailure, $tenant), + ]; + } + + $activeRuns = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->active() + ->count(); + + if ($activeRuns > 0) { + $items[] = [ + 'title' => 'Operations in progress', + 'body' => "{$activeRuns} run(s) are active.", + 'url' => OperationRunLinks::index($tenant), + ]; + } + + return [ + 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, + 'items' => $items, + ]; + } +} diff --git a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php new file mode 100644 index 0000000..8483da0 --- /dev/null +++ b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php @@ -0,0 +1,49 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'findings' => collect(), + ]; + } + + $tenantId = (int) $tenant->getKey(); + + /** @var Collection $findings */ + $findings = Finding::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, + ]; + } +} diff --git a/app/Filament/Widgets/Dashboard/RecentOperations.php b/app/Filament/Widgets/Dashboard/RecentOperations.php new file mode 100644 index 0000000..a5640b6 --- /dev/null +++ b/app/Filament/Widgets/Dashboard/RecentOperations.php @@ -0,0 +1,57 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'runs' => collect(), + 'viewRunBaseUrl' => null, + ]; + } + + $tenantId = (int) $tenant->getKey(); + + /** @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 [ + 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, + 'runs' => $runs, + ]; + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 76385ed..6684dfe 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -3,12 +3,12 @@ namespace App\Providers\Filament; use App\Filament\Pages\Tenancy\RegisterTenant; +use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; -use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; @@ -51,7 +51,7 @@ public function panel(Panel $panel): Panel ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ - Dashboard::class, + TenantDashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') ->widgets([ diff --git a/app/Support/OpsUx/ActiveRuns.php b/app/Support/OpsUx/ActiveRuns.php new file mode 100644 index 0000000..3b8989d --- /dev/null +++ b/app/Support/OpsUx/ActiveRuns.php @@ -0,0 +1,19 @@ +where('tenant_id', $tenant->getKey()) + ->active() + ->exists(); + } +} diff --git a/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php b/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php new file mode 100644 index 0000000..da6c92a --- /dev/null +++ b/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php @@ -0,0 +1,28 @@ +
+
+
+
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 new file mode 100644 index 0000000..e450088 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/needs-attention.blade.php @@ -0,0 +1,26 @@ +
+
+
Needs Attention
+ + @if (count($items) === 0) +
Nothing urgent right now.
+ @else +
+ @foreach ($items as $item) + +
{{ $item['title'] }}
+
{{ $item['body'] }}
+
+ @endforeach +
+ @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 new file mode 100644 index 0000000..ca3b325 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php @@ -0,0 +1,34 @@ +
+
+
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 new file mode 100644 index 0000000..8aaa690 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/recent-operations.blade.php @@ -0,0 +1,37 @@ +
+
+
Recent Operations
+
+ + +
diff --git a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php new file mode 100644 index 0000000..150e4f9 --- /dev/null +++ b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php @@ -0,0 +1,41 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_NEW, + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + $this->actingAs($user); + + Bus::fake(); + + assertNoOutboundHttp(function () use ($tenant): void { + $this->get(TenantDashboard::getUrl(tenant: $tenant)) + ->assertOk() + ->assertSee('Needs Attention') + ->assertSee('Recent Operations') + ->assertSee('Recent Drift Findings'); + }); + + Bus::assertNothingDispatched(); +}); diff --git a/tests/Feature/Filament/TenantDashboardTenantScopeTest.php b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php new file mode 100644 index 0000000..9f6310c --- /dev/null +++ b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php @@ -0,0 +1,33 @@ +create(); + + Finding::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'subject_external_id' => 'other-tenant-finding', + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'running', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + $this->actingAs($user); + + $this->get(TenantDashboard::getUrl(tenant: $tenant)) + ->assertOk() + ->assertDontSee('other-tenant-finding'); +}); diff --git a/tests/Feature/OpsUx/ActiveRunsTest.php b/tests/Feature/OpsUx/ActiveRunsTest.php new file mode 100644 index 0000000..ba61df8 --- /dev/null +++ b/tests/Feature/OpsUx/ActiveRunsTest.php @@ -0,0 +1,64 @@ +create(); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'initiator_name' => 'System', + ]); + + expect(ActiveRuns::existForTenant($tenant))->toBeFalse(); +}); + +it('returns true when tenant has queued runs', function (): void { + $tenant = Tenant::factory()->create(); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + expect(ActiveRuns::existForTenant($tenant))->toBeTrue(); +}); + +it('returns true when tenant has running runs', function (): void { + $tenant = Tenant::factory()->create(); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'running', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + expect(ActiveRuns::existForTenant($tenant))->toBeTrue(); +}); + +it('is tenant scoped (other tenant active runs do not count)', function (): void { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + OperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'type' => 'inventory.sync', + 'status' => 'running', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + expect(ActiveRuns::existForTenant($tenantA))->toBeFalse(); +});