From e1ed7ae23227be25c2ebfb53d8065c1e3173ea22 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 22 Jan 2026 00:17:23 +0000 Subject: [PATCH] 058-tenant-ui-polish (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kurzbeschreibung Filament-native UI-Polish für das Tenant-Dashboard und zugehörige Inventory/Operations-Ansichten; entfernt alte custom Blade‑Panel-Wrapper (die die dicken Rahmen erzeugten) und ersetzt sie durch Filament‑Widgets (StatsOverview / TableWidget). Keine DB-Migrationen. Änderungen (Kurz) Dashboard: KPI‑Kacheln als StatsOverviewWidget (4 Tiles). Needs‑Attention: sinnvolle Leerstaat‑UI (3 Health‑Checks + Links) und begrenzte, badge‑gestützte Issue‑Liste. Recent Drift Findings & Recent Operations: Filament TableWidget (10 Zeilen), badge‑Spalten für Severity/Status/Outcome, kurze copyable IDs, freundliche Subject‑Labels statt roher UUIDs. Entfernen der alten Blade-Wrapper, die ring- / shadow Klassen erzeugten. Tests aktualisiert/ergänzt, um Tenant‑Scope und DB‑only Garantien zu prüfen. Kleinigkeiten / UI‑Polish in Inventory/Operations-Listen und Panel‑Provider. Wichtige Dateien (Auswahl) DashboardKpis.php NeedsAttention.php RecentDriftFindings.php RecentOperations.php needs-attention.blade.php Tests: TenantDashboardTenantScopeTest.php, inventory/operations test updates Testing / Verifikation Lokale Tests (empfohlen, vor Merge ausführen): Formatter: Filament assets (falls panel assets geändert wurden): Review‑Hinweise (Was prüfen) UI: Dashboard sieht visuell wie Filament‑Demo‑Widgets aus (keine dicken ring- Rahmen mehr). Tables: Primary text zeigt freundliche Labels, nicht UUIDs; IDs sind copyable und kurz dargestellt. Needs‑Attention: Leerstaat zeigt die 3 Health‑Checks + korrekte Links; bei Issues sind Badges und Farben korrekt. Tenant‑Scope: Keine Daten von anderen Tenants leakieren (prüfe die aktualisierten TenantScope‑Tests). Polling: Widgets poll nur wenn nötig (z.B. aktive Runs existieren). Keine externen HTTP‑Calls oder ungeprüfte Jobs während Dashboard‑Rendering. Deployment / Migrations Keine Datenbankmigrationen. Empfohlen: nach Merge ./vendor/bin/sail artisan filament:assets in Deployment‑Pipeline prüfen, falls neue panel assets registriert wurden. Zusammenfassung für den Reviewer Zweck: Entfernen der alten, handgebauten Panel‑Wrappers und Vereinheitlichung der Dashboard‑UX mit Filament‑nativen Komponenten; kleinere UI‑Polish in Inventory/Operations. Tests: Unit/Feature tests für Tenant‑Scope und DB‑only Verhalten wurden aktualisiert; bitte laufen lassen. Merge: Branch 058-tenant-ui-polish → dev (protected) via Pull Request in Gitea. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/70 --- .dockerignore | 7 + .prettierignore | 4 + .../Clusters/Inventory/InventoryCluster.php | 16 ++ app/Filament/Pages/InventoryCoverage.php | 21 +- app/Filament/Pages/InventoryLanding.php | 246 +----------------- .../Resources/InventoryItemResource.php | 17 +- .../Pages/ListInventoryItems.php | 230 ++++++++++++++++ .../Resources/InventorySyncRunResource.php | 12 +- .../Pages/ListInventorySyncRuns.php | 8 + .../Resources/OperationRunResource.php | 5 - .../Pages/ListOperationRuns.php | 53 ++++ .../Widgets/Dashboard/DashboardKpis.php | 49 ++-- .../Widgets/Dashboard/NeedsAttention.php | 41 +++ .../Widgets/Dashboard/RecentDriftFindings.php | 88 +++++-- .../Widgets/Dashboard/RecentOperations.php | 99 ++++--- .../Widgets/Inventory/InventoryKpiHeader.php | 162 ++++++++++++ .../Operations/OperationsKpiHeader.php | 136 ++++++++++ app/Providers/Filament/AdminPanelProvider.php | 1 + app/Support/Inventory/InventoryKpiBadges.php | 46 ++++ .../Inventory/InventoryPolicyTypeMeta.php | 101 +++++++ .../Inventory/InventorySyncStatusBadge.php | 55 ++++ .../dashboard/dashboard-kpis.blade.php | 28 -- .../dashboard/needs-attention.blade.php | 61 +++-- .../dashboard/recent-drift-findings.blade.php | 34 --- .../dashboard/recent-operations.blade.php | 37 --- specs/058-tenant-ui-polish/tasks.md | 86 +++--- .../Filament/InventoryHubDbOnlyTest.php | 49 ++++ tests/Feature/Filament/InventoryPagesTest.php | 52 +++- .../TenantDashboardTenantScopeTest.php | 9 +- .../Inventory/InventorySyncButtonTest.php | 18 +- .../InventorySyncStartSurfaceTest.php | 4 +- .../Monitoring/OperationsDbOnlyTest.php | 11 +- .../Monitoring/OperationsTenantScopeTest.php | 82 ++++++ tests/Feature/RunStartAuthorizationTest.php | 4 +- 34 files changed, 1370 insertions(+), 502 deletions(-) create mode 100644 app/Filament/Clusters/Inventory/InventoryCluster.php create mode 100644 app/Filament/Widgets/Inventory/InventoryKpiHeader.php create mode 100644 app/Filament/Widgets/Operations/OperationsKpiHeader.php create mode 100644 app/Support/Inventory/InventoryKpiBadges.php create mode 100644 app/Support/Inventory/InventoryPolicyTypeMeta.php create mode 100644 app/Support/Inventory/InventorySyncStatusBadge.php delete mode 100644 resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php delete mode 100644 resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php delete mode 100644 resources/views/filament/widgets/dashboard/recent-operations.blade.php create mode 100644 tests/Feature/Filament/InventoryHubDbOnlyTest.php diff --git a/.dockerignore b/.dockerignore index 9503447..95f1ba0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,21 @@ node_modules/ vendor/ .git/ +.DS_Store +Thumbs.db .env .env.* *.log npm-debug.log* yarn-debug.log* yarn-error.log* +*.tmp +*.swp public/build/ public/hot/ +public/storage/ +storage/framework/ +storage/logs/ storage/debugbar/ storage/*.key /references/ diff --git a/.prettierignore b/.prettierignore index 9cc4941..a2a9239 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,7 +3,11 @@ dist/ build/ public/build/ public/hot/ +public/storage/ coverage/ +vendor/ +storage/ +bootstrap/cache/ package-lock.json yarn.lock pnpm-lock.yaml diff --git a/app/Filament/Clusters/Inventory/InventoryCluster.php b/app/Filament/Clusters/Inventory/InventoryCluster.php new file mode 100644 index 0000000..9312759 --- /dev/null +++ b/app/Filament/Clusters/Inventory/InventoryCluster.php @@ -0,0 +1,16 @@ +> */ @@ -29,12 +43,9 @@ class InventoryCoverage extends Page public function mount(): void { - $policyTypes = config('tenantpilot.supported_policy_types', []); - $foundationTypes = config('tenantpilot.foundation_types', []); - $resolver = app(CoverageCapabilitiesResolver::class); - $this->supportedPolicyTypes = collect(is_array($policyTypes) ? $policyTypes : []) + $this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported()) ->map(function (array $row) use ($resolver): array { $type = (string) ($row['type'] ?? ''); @@ -44,7 +55,7 @@ public function mount(): void }) ->all(); - $this->foundationTypes = collect(is_array($foundationTypes) ? $foundationTypes : []) + $this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations()) ->map(function (array $row): array { return array_merge($row, [ 'dependencies' => false, diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php index d4fee81..07c3ef4 100644 --- a/app/Filament/Pages/InventoryLanding.php +++ b/app/Filament/Pages/InventoryLanding.php @@ -2,255 +2,37 @@ namespace App\Filament\Pages; +use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Resources\InventoryItemResource; -use App\Filament\Resources\InventorySyncRunResource; -use App\Jobs\RunInventorySyncJob; -use App\Models\InventorySyncRun; +use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Models\Tenant; -use App\Models\User; -use App\Services\Intune\AuditLogger; -use App\Services\Inventory\InventorySyncService; -use App\Services\OperationRunService; -use App\Support\OperationRunLinks; -use App\Support\OpsUx\OperationUxPresenter; -use App\Support\OpsUx\OpsUxBrowserEvents; use BackedEnum; -use Filament\Actions\Action; -use Filament\Actions\Action as HintAction; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\Toggle; -use Filament\Notifications\Notification; use Filament\Pages\Page; -use Filament\Support\Enums\Size; use UnitEnum; class InventoryLanding extends Page { + protected static bool $shouldRegisterNavigation = false; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; - protected static ?string $navigationLabel = 'Inventory'; + protected static ?string $navigationLabel = 'Overview'; + + protected static ?string $cluster = InventoryCluster::class; protected string $view = 'filament.pages.inventory-landing'; - protected function getHeaderActions(): array + public function mount(): void + { + $this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current())); + } + + protected function getHeaderWidgets(): array { return [ - Action::make('run_inventory_sync') - ->label('Run Inventory Sync') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->form([ - Select::make('policy_types') - ->label('Policy types') - ->multiple() - ->searchable() - ->preload() - ->native(false) - ->hintActions([ - fn (Select $component): HintAction => HintAction::make('select_all_policy_types') - ->label('Select all') - ->link() - ->size(Size::Small) - ->action(function (InventorySyncService $inventorySyncService) use ($component): void { - $component->state($inventorySyncService->defaultSelectionPayload()['policy_types']); - }), - fn (Select $component): HintAction => HintAction::make('clear_policy_types') - ->label('Clear') - ->link() - ->size(Size::Small) - ->action(function () use ($component): void { - $component->state([]); - }), - ]) - ->options(function (): array { - return collect(config('tenantpilot.supported_policy_types', [])) - ->filter(fn (array $meta): bool => filled($meta['type'] ?? null)) - ->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other')) - ->mapWithKeys(function ($items, string $category): array { - $options = collect($items) - ->mapWithKeys(function (array $meta): array { - $type = (string) $meta['type']; - $label = (string) ($meta['label'] ?? $type); - $platform = (string) ($meta['platform'] ?? 'all'); - - return [$type => "{$label} • {$platform}"]; - }) - ->all(); - - return [$category => $options]; - }) - ->all(); - }) - ->columnSpanFull(), - Toggle::make('include_foundations') - ->label('Include foundation types') - ->helperText('Include scope tags, assignment filters, and notification templates.') - ->default(true) - ->dehydrated() - ->rules(['boolean']) - ->columnSpanFull(), - Toggle::make('include_dependencies') - ->label('Include dependencies') - ->helperText('Include dependency extraction where supported.') - ->default(true) - ->dehydrated() - ->rules(['boolean']) - ->columnSpanFull(), - Hidden::make('tenant_id') - ->default(fn (): ?string => Tenant::current()?->getKey()) - ->dehydrated(), - ]) - ->visible(function (): bool { - $user = auth()->user(); - if (! $user instanceof User) { - return false; - } - - return $user->canSyncTenant(Tenant::current()); - }) - ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - - $user = auth()->user(); - if (! $user instanceof User) { - abort(403, 'Not allowed'); - } - - if (! $user->canSyncTenant($tenant)) { - abort(403, 'Not allowed'); - } - - $requestedTenantId = $data['tenant_id'] ?? null; - if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) { - Notification::make() - ->title('Not allowed') - ->danger() - ->send(); - - abort(403, 'Not allowed'); - } - - $selectionPayload = $inventorySyncService->defaultSelectionPayload(); - if (array_key_exists('policy_types', $data)) { - $selectionPayload['policy_types'] = $data['policy_types']; - } - if (array_key_exists('include_foundations', $data)) { - $selectionPayload['include_foundations'] = (bool) $data['include_foundations']; - } - if (array_key_exists('include_dependencies', $data)) { - $selectionPayload['include_dependencies'] = (bool) $data['include_dependencies']; - } - $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'inventory.sync', - inputs: $computed['selection'], - initiator: $user - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Inventory sync already active') - ->body('This operation is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - - return; - } - - // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) - $existing = InventorySyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_hash', $computed['selection_hash']) - ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) - ->first(); - - // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. - if ($existing instanceof InventorySyncRun) { - Notification::make() - ->title('Inventory sync already active') - ->body('A matching inventory sync run is already pending or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); - - $policyTypes = $computed['selection']['policy_types'] ?? []; - if (! is_array($policyTypes)) { - $policyTypes = []; - } - - $auditLogger->log( - tenant: $tenant, - action: 'inventory.sync.dispatched', - context: [ - 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - ], - ], - actorId: $user->id, - actorEmail: $user->email, - actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, - ); - - $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { - RunInventorySyncJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->id, - operationRun: $opRun - ); - }); - - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - }), + InventoryKpiHeader::class, ]; } - - public function getInventoryItemsUrl(): string - { - return InventoryItemResource::getUrl('index', tenant: Tenant::current()); - } - - public function getSyncRunsUrl(): string - { - return InventorySyncRunResource::getUrl('index', tenant: Tenant::current()); - } - - public function getCoverageUrl(): string - { - return InventoryCoverage::getUrl(tenant: Tenant::current()); - } } diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 4898bf6..3c342dd 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -2,12 +2,14 @@ namespace App\Filament\Resources; +use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Resources\InventoryItemResource\Pages; use App\Models\InventoryItem; use App\Models\Tenant; use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Support\Enums\RelationshipType; +use App\Support\Inventory\InventoryPolicyTypeMeta; use BackedEnum; use Filament\Actions; use Filament\Infolists\Components\TextEntry; @@ -24,6 +26,10 @@ class InventoryItemResource extends Resource { protected static ?string $model = InventoryItem::class; + protected static ?string $cluster = InventoryCluster::class; + + protected static ?int $navigationSort = 1; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; @@ -190,8 +196,7 @@ private static function typeMeta(?string $type): array return []; } - return collect(static::allTypeMeta()) - ->firstWhere('type', $type) ?? []; + return InventoryPolicyTypeMeta::metaFor($type); } /** @@ -199,12 +204,6 @@ private static function typeMeta(?string $type): array */ private static function allTypeMeta(): array { - $supported = config('tenantpilot.supported_policy_types', []); - $foundations = config('tenantpilot.foundation_types', []); - - return array_merge( - is_array($supported) ? $supported : [], - is_array($foundations) ? $foundations : [], - ); + return InventoryPolicyTypeMeta::all(); } } diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index fea1cdb..d37abf9 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -3,9 +3,239 @@ namespace App\Filament\Resources\InventoryItemResource\Pages; use App\Filament\Resources\InventoryItemResource; +use App\Filament\Widgets\Inventory\InventoryKpiHeader; +use App\Jobs\RunInventorySyncJob; +use App\Models\InventorySyncRun; +use App\Models\Tenant; +use App\Models\User; +use App\Services\Intune\AuditLogger; +use App\Services\Inventory\InventorySyncService; +use App\Services\OperationRunService; +use App\Support\Inventory\InventoryPolicyTypeMeta; +use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; +use Filament\Actions\Action; +use Filament\Actions\Action as HintAction; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Toggle; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Enums\Size; class ListInventoryItems extends ListRecords { protected static string $resource = InventoryItemResource::class; + + protected function getHeaderWidgets(): array + { + return [ + InventoryKpiHeader::class, + ]; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('run_inventory_sync') + ->label('Run Inventory Sync') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->form([ + Select::make('policy_types') + ->label('Policy types') + ->multiple() + ->searchable() + ->preload() + ->native(false) + ->hintActions([ + fn (Select $component): HintAction => HintAction::make('select_all_policy_types') + ->label('Select all') + ->link() + ->size(Size::Small) + ->action(function (InventorySyncService $inventorySyncService) use ($component): void { + $component->state($inventorySyncService->defaultSelectionPayload()['policy_types']); + }), + fn (Select $component): HintAction => HintAction::make('clear_policy_types') + ->label('Clear') + ->link() + ->size(Size::Small) + ->action(function () use ($component): void { + $component->state([]); + }), + ]) + ->options(function (): array { + return collect(InventoryPolicyTypeMeta::supported()) + ->filter(fn (array $meta): bool => filled($meta['type'] ?? null)) + ->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other')) + ->mapWithKeys(function ($items, string $category): array { + $options = collect($items) + ->mapWithKeys(function (array $meta): array { + $type = (string) $meta['type']; + $label = (string) ($meta['label'] ?? $type); + $platform = (string) ($meta['platform'] ?? 'all'); + + return [$type => "{$label} • {$platform}"]; + }) + ->all(); + + return [$category => $options]; + }) + ->all(); + }) + ->columnSpanFull(), + Toggle::make('include_foundations') + ->label('Include foundation types') + ->helperText('Include scope tags, assignment filters, and notification templates.') + ->default(true) + ->dehydrated() + ->rules(['boolean']) + ->columnSpanFull(), + Toggle::make('include_dependencies') + ->label('Include dependencies') + ->helperText('Include dependency extraction where supported.') + ->default(true) + ->dehydrated() + ->rules(['boolean']) + ->columnSpanFull(), + Hidden::make('tenant_id') + ->default(fn (): ?string => Tenant::current()?->getKey()) + ->dehydrated(), + ]) + ->visible(function (): bool { + $user = auth()->user(); + if (! $user instanceof User) { + return false; + } + + return $user->canSyncTenant(Tenant::current()); + }) + ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $user->canSyncTenant($tenant)) { + abort(403, 'Not allowed'); + } + + $requestedTenantId = $data['tenant_id'] ?? null; + if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) { + Notification::make() + ->title('Not allowed') + ->danger() + ->send(); + + abort(403, 'Not allowed'); + } + + $selectionPayload = $inventorySyncService->defaultSelectionPayload(); + if (array_key_exists('policy_types', $data)) { + $selectionPayload['policy_types'] = $data['policy_types']; + } + if (array_key_exists('include_foundations', $data)) { + $selectionPayload['include_foundations'] = (bool) $data['include_foundations']; + } + if (array_key_exists('include_dependencies', $data)) { + $selectionPayload['include_dependencies'] = (bool) $data['include_dependencies']; + } + $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'inventory.sync', + inputs: $computed['selection'], + initiator: $user + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Inventory sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + return; + } + + // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) + $existing = InventorySyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_hash', $computed['selection_hash']) + ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) + ->first(); + + // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. + if ($existing instanceof InventorySyncRun) { + Notification::make() + ->title('Inventory sync already active') + ->body('A matching inventory sync run is already pending or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); + + $policyTypes = $computed['selection']['policy_types'] ?? []; + if (! is_array($policyTypes)) { + $policyTypes = []; + } + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.dispatched', + context: [ + 'metadata' => [ + 'inventory_sync_run_id' => $run->id, + 'selection_hash' => $run->selection_hash, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'inventory_sync_run', + resourceId: (string) $run->id, + ); + + $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { + RunInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + inventorySyncRunId: (int) $run->id, + operationRun: $opRun + ); + }); + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + }), + ]; + } } diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index 731d120..5f32f5c 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Resources\InventorySyncRunResource\Pages; use App\Models\InventorySyncRun; use App\Models\Tenant; @@ -22,12 +23,21 @@ class InventorySyncRunResource extends Resource { protected static ?string $model = InventorySyncRun::class; - protected static bool $shouldRegisterNavigation = false; + protected static bool $shouldRegisterNavigation = true; + + protected static ?string $cluster = InventoryCluster::class; + + protected static ?int $navigationSort = 2; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; + public static function getNavigationLabel(): string + { + return 'Sync History'; + } + public static function form(Schema $schema): Schema { return $schema; diff --git a/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php b/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php index 024f46e..61536de 100644 --- a/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php +++ b/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php @@ -3,9 +3,17 @@ namespace App\Filament\Resources\InventorySyncRunResource\Pages; use App\Filament\Resources\InventorySyncRunResource; +use App\Filament\Widgets\Inventory\InventoryKpiHeader; use Filament\Resources\Pages\ListRecords; class ListInventorySyncRuns extends ListRecords { protected static string $resource = InventorySyncRunResource::class; + + protected function getHeaderWidgets(): array + { + return [ + InventoryKpiHeader::class, + ]; + } } diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 91e8a08..3cc29b2 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -144,11 +144,6 @@ public static function table(Table $table): Table { return $table ->defaultSort('created_at', 'desc') - ->modifyQueryUsing(function (Builder $query): Builder { - $tenantId = Tenant::current()?->getKey(); - - return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); - }) ->columns([ Tables\Columns\TextColumn::make('status') ->badge() diff --git a/app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php b/app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php index cbec2a8..5342421 100644 --- a/app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +++ b/app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php @@ -3,9 +3,62 @@ namespace App\Filament\Resources\OperationRunResource\Pages; use App\Filament\Resources\OperationRunResource; +use App\Filament\Widgets\Operations\OperationsKpiHeader; +use App\Models\Tenant; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\OpsUx\ActiveRuns; +use Filament\Facades\Filament; use Filament\Resources\Pages\ListRecords; +use Filament\Schemas\Components\Tabs\Tab; +use Illuminate\Database\Eloquent\Builder; class ListOperationRuns extends ListRecords { protected static string $resource = OperationRunResource::class; + + protected function getHeaderWidgets(): array + { + return [ + OperationsKpiHeader::class, + ]; + } + + /** + * @return array + */ + public function getTabs(): array + { + return [ + 'all' => Tab::make(), + 'active' => Tab::make() + ->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [ + OperationRunStatus::Queued->value, + OperationRunStatus::Running->value, + ])), + 'succeeded' => Tab::make() + ->modifyQueryUsing(fn (Builder $query): Builder => $query + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::Succeeded->value)), + 'partial' => Tab::make() + ->modifyQueryUsing(fn (Builder $query): Builder => $query + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::PartiallySucceeded->value)), + 'failed' => Tab::make() + ->modifyQueryUsing(fn (Builder $query): Builder => $query + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::Failed->value)), + ]; + } + + protected function getTablePollingInterval(): ?string + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return ActiveRuns::existForTenant($tenant) ? '10s' : null; + } } 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/app/Filament/Widgets/Inventory/InventoryKpiHeader.php b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php new file mode 100644 index 0000000..f4128ea --- /dev/null +++ b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php @@ -0,0 +1,162 @@ + + */ + protected function getStats(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + Stat::make('Total items', 0), + Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'), + Stat::make('Last inventory sync', '—'), + Stat::make('Active ops', 0), + Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'), + ]; + } + + $tenantId = (int) $tenant->getKey(); + + /** @var array $countsByPolicyType */ + $countsByPolicyType = InventoryItem::query() + ->where('tenant_id', $tenantId) + ->selectRaw('policy_type, COUNT(*) as aggregate') + ->groupBy('policy_type') + ->pluck('aggregate', 'policy_type') + ->map(fn ($value): int => (int) $value) + ->all(); + + $totalItems = array_sum($countsByPolicyType); + + $restorableItems = 0; + $partialItems = 0; + $riskItems = 0; + + foreach ($countsByPolicyType as $policyType => $count) { + if (InventoryPolicyTypeMeta::isRestorable($policyType)) { + $restorableItems += $count; + } elseif (InventoryPolicyTypeMeta::isPartial($policyType)) { + $partialItems += $count; + } + + if (InventoryPolicyTypeMeta::isHighRisk($policyType)) { + $riskItems += $count; + } + } + + $coveragePercent = $totalItems > 0 + ? (int) round(($restorableItems / $totalItems) * 100) + : 0; + + $lastRun = InventorySyncRun::query() + ->where('tenant_id', $tenantId) + ->latest('id') + ->first(); + + $lastInventorySyncTimeLabel = '—'; + $lastInventorySyncStatusLabel = '—'; + $lastInventorySyncStatusColor = 'gray'; + $lastInventorySyncStatusIcon = 'heroicon-m-clock'; + $lastInventorySyncViewUrl = null; + + if ($lastRun instanceof InventorySyncRun) { + $timestamp = $lastRun->finished_at ?? $lastRun->started_at; + + if ($timestamp) { + $lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]); + } + + $status = (string) ($lastRun->status ?? ''); + + $badge = InventorySyncStatusBadge::for($status); + $lastInventorySyncStatusLabel = $badge['label']; + $lastInventorySyncStatusColor = $badge['color']; + $lastInventorySyncStatusIcon = $badge['icon']; + + $lastInventorySyncViewUrl = InventorySyncRunResource::getUrl('view', ['record' => $lastRun], tenant: $tenant); + } + + $badgeColor = $lastInventorySyncStatusColor; + + $lastInventorySyncDescription = Blade::render(<<<'BLADE' +
+ + {{ $statusLabel }} + + + @if ($viewUrl) + + View run + + @endif +
+ BLADE, [ + 'badgeColor' => $badgeColor, + 'statusLabel' => $lastInventorySyncStatusLabel, + 'viewUrl' => $lastInventorySyncViewUrl, + ]); + + $activeOps = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->active() + ->count(); + + $inventoryOps = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('type', 'inventory.sync') + ->active() + ->count(); + + $resolver = app(CoverageCapabilitiesResolver::class); + + $dependenciesItems = 0; + foreach ($countsByPolicyType as $policyType => $count) { + if ($policyType !== '' && $resolver->supportsDependencies($policyType)) { + $dependenciesItems += $count; + } + } + + return [ + Stat::make('Total items', $totalItems), + Stat::make('Coverage', $coveragePercent.'%') + ->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))), + Stat::make('Last inventory sync', $lastInventorySyncTimeLabel) + ->description(new HtmlString($lastInventorySyncDescription)), + Stat::make('Active ops', $activeOps), + Stat::make('Inventory ops', $inventoryOps) + ->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))), + ]; + } +} diff --git a/app/Filament/Widgets/Operations/OperationsKpiHeader.php b/app/Filament/Widgets/Operations/OperationsKpiHeader.php new file mode 100644 index 0000000..0ba00a8 --- /dev/null +++ b/app/Filament/Widgets/Operations/OperationsKpiHeader.php @@ -0,0 +1,136 @@ + + */ + protected function getStats(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + Stat::make('Total Runs (30 days)', 0), + Stat::make('Active Runs', 0), + Stat::make('Failed/Partial (7 days)', 0), + Stat::make('Avg Duration (7 days)', '—'), + ]; + } + + $tenantId = (int) $tenant->getKey(); + + $totalRuns30Days = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('created_at', '>=', now()->subDays(30)) + ->count(); + + $activeRuns = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->whereIn('status', [ + OperationRunStatus::Queued->value, + OperationRunStatus::Running->value, + ]) + ->count(); + + $failedOrPartial7Days = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('status', OperationRunStatus::Completed->value) + ->whereIn('outcome', [ + OperationRunOutcome::Failed->value, + OperationRunOutcome::PartiallySucceeded->value, + ]) + ->where('completed_at', '>=', now()->subDays(7)) + ->count(); + + /** @var Collection $recentCompletedRuns */ + $recentCompletedRuns = OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('status', OperationRunStatus::Completed->value) + ->whereNotNull('started_at') + ->whereNotNull('completed_at') + ->where('completed_at', '>=', now()->subDays(7)) + ->latest('id') + ->limit(200) + ->get(['started_at', 'completed_at']); + + $durations = $recentCompletedRuns + ->map(function (OperationRun $run): ?int { + if (! $run->started_at || ! $run->completed_at) { + return null; + } + + $seconds = $run->completed_at->diffInSeconds($run->started_at); + + if (is_int($seconds)) { + return $seconds; + } + + return (int) round((float) $seconds); + }) + ->filter(fn (?int $seconds): bool => is_int($seconds) && $seconds > 0) + ->values(); + + $avgDuration7Days = '—'; + if ($durations->isNotEmpty()) { + $avgDurationSeconds = (int) round($durations->avg() ?? 0); + $avgDuration7Days = self::formatDurationSeconds($avgDurationSeconds); + } + + return [ + Stat::make('Total Runs (30 days)', $totalRuns30Days), + Stat::make('Active Runs', $activeRuns), + Stat::make('Failed/Partial (7 days)', $failedOrPartial7Days), + Stat::make('Avg Duration (7 days)', $avgDuration7Days), + ]; + } + + private static function formatDurationSeconds(int $seconds): string + { + if ($seconds <= 0) { + return '—'; + } + + if ($seconds < 60) { + return $seconds.'s'; + } + + $interval = CarbonInterval::seconds($seconds)->cascade(); + + if ($seconds < 3600) { + return sprintf('%dm %ds', $interval->minutes, $interval->seconds); + } + + return sprintf('%dh %dm', $interval->hours, $interval->minutes); + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 6684dfe..bf4ea95 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -48,6 +48,7 @@ public function panel(Panel $panel): Panel ? view('livewire.bulk-operation-progress-wrapper')->render() : '' ) + ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ diff --git a/app/Support/Inventory/InventoryKpiBadges.php b/app/Support/Inventory/InventoryKpiBadges.php new file mode 100644 index 0000000..24d67f4 --- /dev/null +++ b/app/Support/Inventory/InventoryKpiBadges.php @@ -0,0 +1,46 @@ + + + Restorable {{ $restorableCount }} + + + + Partial {{ $partialCount }} + + + BLADE, [ + 'restorableCount' => $restorableCount, + 'partialCount' => $partialCount, + ]); + } + + public static function inventoryOps(int $dependenciesCount, int $riskCount): string + { + return Blade::render(<<<'BLADE' +
+ + Dependencies {{ $dependenciesCount }} + + + + Risk {{ $riskCount }} + +
+ BLADE, [ + 'dependenciesCount' => $dependenciesCount, + 'riskCount' => $riskCount, + ]); + } +} diff --git a/app/Support/Inventory/InventoryPolicyTypeMeta.php b/app/Support/Inventory/InventoryPolicyTypeMeta.php new file mode 100644 index 0000000..f72eadc --- /dev/null +++ b/app/Support/Inventory/InventoryPolicyTypeMeta.php @@ -0,0 +1,101 @@ +> + */ + public static function supported(): array + { + $supported = config('tenantpilot.supported_policy_types', []); + + return is_array($supported) ? $supported : []; + } + + /** + * @return array> + */ + public static function foundations(): array + { + $foundations = config('tenantpilot.foundation_types', []); + + return is_array($foundations) ? $foundations : []; + } + + /** + * @return array> + */ + public static function byType(): array + { + return collect(static::all()) + ->filter(fn (array $row): bool => filled($row['type'] ?? null)) + ->keyBy(fn (array $row): string => (string) $row['type']) + ->all(); + } + + /** + * @return array + */ + public static function metaFor(?string $type): array + { + if (! filled($type)) { + return []; + } + + return static::byType()[(string) $type] ?? []; + } + + public static function restoreMode(?string $type): ?string + { + $restore = static::metaFor($type)['restore'] ?? null; + + return is_string($restore) ? $restore : null; + } + + public static function riskLevel(?string $type): ?string + { + $risk = static::metaFor($type)['risk'] ?? null; + + return is_string($risk) ? $risk : null; + } + + public static function isRestorable(?string $type): bool + { + return static::restoreMode($type) === 'enabled'; + } + + public static function isPartial(?string $type): bool + { + $restore = static::restoreMode($type); + + return filled($restore) && $restore !== 'enabled'; + } + + public static function isHighRisk(?string $type): bool + { + $risk = static::riskLevel($type); + + return is_string($risk) && str_contains($risk, 'high'); + } +} diff --git a/app/Support/Inventory/InventorySyncStatusBadge.php b/app/Support/Inventory/InventorySyncStatusBadge.php new file mode 100644 index 0000000..ffc18ed --- /dev/null +++ b/app/Support/Inventory/InventorySyncStatusBadge.php @@ -0,0 +1,55 @@ + 'Success', + InventorySyncRun::STATUS_PARTIAL => 'Partial', + InventorySyncRun::STATUS_FAILED => 'Failed', + InventorySyncRun::STATUS_RUNNING => 'Running', + InventorySyncRun::STATUS_PENDING => 'Pending', + InventorySyncRun::STATUS_SKIPPED => 'Skipped', + 'queued' => 'Queued', + default => '—', + }; + + $color = match ($status) { + InventorySyncRun::STATUS_SUCCESS => 'success', + InventorySyncRun::STATUS_PARTIAL => 'warning', + InventorySyncRun::STATUS_FAILED => 'danger', + InventorySyncRun::STATUS_RUNNING => 'info', + InventorySyncRun::STATUS_PENDING, 'queued' => 'gray', + InventorySyncRun::STATUS_SKIPPED => 'gray', + default => 'gray', + }; + + $icon = match ($status) { + InventorySyncRun::STATUS_SUCCESS => 'heroicon-m-check-circle', + InventorySyncRun::STATUS_PARTIAL => 'heroicon-m-exclamation-triangle', + InventorySyncRun::STATUS_FAILED => 'heroicon-m-x-circle', + InventorySyncRun::STATUS_RUNNING => 'heroicon-m-arrow-path', + InventorySyncRun::STATUS_PENDING, 'queued' => 'heroicon-m-clock', + InventorySyncRun::STATUS_SKIPPED => 'heroicon-m-minus-circle', + default => 'heroicon-m-clock', + }; + + return [ + 'label' => $label, + 'color' => $color, + 'icon' => $icon, + ]; + } +} 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/specs/058-tenant-ui-polish/tasks.md b/specs/058-tenant-ui-polish/tasks.md index c4ba39c..b00f851 100644 --- a/specs/058-tenant-ui-polish/tasks.md +++ b/specs/058-tenant-ui-polish/tasks.md @@ -12,15 +12,15 @@ # Tasks: Tenant UI Polish (Dashboard + Inventory Hub + Operations) ## Phase 1: Setup (Shared Infrastructure) -- [ ] T001 Confirm feature inputs exist: specs/058-tenant-ui-polish/spec.md, specs/058-tenant-ui-polish/plan.md -- [ ] T002 Confirm Phase 0/1 artifacts exist: specs/058-tenant-ui-polish/research.md, specs/058-tenant-ui-polish/data-model.md, specs/058-tenant-ui-polish/contracts/ui.md, specs/058-tenant-ui-polish/contracts/polling.md, specs/058-tenant-ui-polish/quickstart.md +- [X] T001 Confirm feature inputs exist: specs/058-tenant-ui-polish/spec.md, specs/058-tenant-ui-polish/plan.md +- [X] T002 Confirm Phase 0/1 artifacts exist: specs/058-tenant-ui-polish/research.md, specs/058-tenant-ui-polish/data-model.md, specs/058-tenant-ui-polish/contracts/ui.md, specs/058-tenant-ui-polish/contracts/polling.md, specs/058-tenant-ui-polish/quickstart.md --- ## Phase 2: Foundational (Blocking Prerequisites) -- [ ] T003 Create shared helper to detect “active runs exist” for tenant polling in app/Support/OpsUx/ActiveRuns.php -- [ ] T004 [P] Add focused tests for the helper in tests/Feature/OpsUx/ActiveRunsTest.php +- [X] T003 Create shared helper to detect “active runs exist” for tenant polling in app/Support/OpsUx/ActiveRuns.php +- [X] T004 [P] Add focused tests for the helper in tests/Feature/OpsUx/ActiveRunsTest.php **Checkpoint**: Shared polling predicate exists and is covered. @@ -34,20 +34,20 @@ ## Phase 3: User Story 1 — Drift-first tenant dashboard (Priority: P1) 🎯 MV ### Tests (US1) -- [ ] T005 [P] [US1] Add DB-only render test (no outbound HTTP, no background work) in tests/Feature/Filament/TenantDashboardDbOnlyTest.php -- [ ] T006 [P] [US1] Add tenant isolation test (no cross-tenant leakage) in tests/Feature/Filament/TenantDashboardTenantScopeTest.php +- [X] T005 [P] [US1] Add DB-only render test (no outbound HTTP, no background work) in tests/Feature/Filament/TenantDashboardDbOnlyTest.php +- [X] T006 [P] [US1] Add tenant isolation test (no cross-tenant leakage) in tests/Feature/Filament/TenantDashboardTenantScopeTest.php ### Implementation (US1) -- [ ] T007 [US1] Create tenant dashboard page in app/Filament/Pages/TenantDashboard.php -- [ ] T008 [US1] Register the tenant dashboard page in app/Providers/Filament/AdminPanelProvider.php (replace default Dashboard page entry) -- [ ] T009 [P] [US1] Create dashboard KPI widget(s) in app/Filament/Widgets/Dashboard/DashboardKpis.php -- [ ] T010 [P] [US1] Create “Needs Attention” widget in app/Filament/Widgets/Dashboard/NeedsAttention.php -- [ ] T011 [P] [US1] Create “Recent Drift Findings” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentDriftFindings.php -- [ ] T012 [P] [US1] Create “Recent Operations” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentOperations.php -- [ ] T013 [US1] Wire widgets into the dashboard page in app/Filament/Pages/TenantDashboard.php (header/sections) and implement conditional polling per specs/058-tenant-ui-polish/contracts/polling.md -- [ ] T014 [US1] Implement drift stale rule (7 days) + CTA wiring in app/Filament/Widgets/Dashboard/NeedsAttention.php -- [ ] T015 [US1] Ensure all dashboard queries are tenant-scoped + DB-only in app/Filament/Pages/TenantDashboard.php and app/Filament/Widgets/Dashboard/*.php +- [X] T007 [US1] Create tenant dashboard page in app/Filament/Pages/TenantDashboard.php +- [X] T008 [US1] Register the tenant dashboard page in app/Providers/Filament/AdminPanelProvider.php (replace default Dashboard page entry) +- [X] T009 [P] [US1] Create dashboard KPI widget(s) in app/Filament/Widgets/Dashboard/DashboardKpis.php +- [X] T010 [P] [US1] Create “Needs Attention” widget in app/Filament/Widgets/Dashboard/NeedsAttention.php +- [X] T011 [P] [US1] Create “Recent Drift Findings” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentDriftFindings.php +- [X] T012 [P] [US1] Create “Recent Operations” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentOperations.php +- [X] T013 [US1] Wire widgets into the dashboard page in app/Filament/Pages/TenantDashboard.php (header/sections) and implement conditional polling per specs/058-tenant-ui-polish/contracts/polling.md +- [X] T014 [US1] Implement drift stale rule (7 days) + CTA wiring in app/Filament/Widgets/Dashboard/NeedsAttention.php +- [X] T015 [US1] Ensure all dashboard queries are tenant-scoped + DB-only in app/Filament/Pages/TenantDashboard.php and app/Filament/Widgets/Dashboard/*.php **Checkpoint**: US1 is shippable as an MVP. @@ -61,22 +61,22 @@ ## Phase 4: User Story 2 — Inventory becomes a hub module (Priority: P2) ### Tests (US2) -- [ ] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php -- [ ] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php +- [X] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php +- [X] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php ### Implementation (US2) -- [ ] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`) -- [ ] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php -- [ ] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php -- [ ] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php -- [ ] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php -- [ ] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php -- [ ] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php -- [ ] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page) -- [ ] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page) -- [ ] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts) -- [ ] T041 [US2] Inventory coverage semantics reference (A2) +- [X] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`) +- [X] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php +- [X] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php +- [X] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php +- [X] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php +- [X] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php +- [X] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php +- [X] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page) +- [X] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page) +- [X] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts) +- [X] T041 [US2] Inventory coverage semantics reference (A2) - Identify and document the exact source-of-truth fields for Inventory KPI aggregation: - `inventory_items.policy_type` - `config('tenantpilot.supported_policy_types')` meta fields (`restore`, `risk`) @@ -85,8 +85,8 @@ ### Implementation (US2) - DoD: - One canonical place documented and referenced by inventory KPIs. - No “magic” or duplicated classification logic across pages/widgets. -- [ ] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed) -- [ ] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only) +- [X] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed) +- [X] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only) **Checkpoint**: Inventory hub behaves as a module with consistent sub-navigation + header. @@ -100,21 +100,21 @@ ## Phase 5: User Story 3 — Operations index “Orders-style” (Priority: P3) ### Tests (US3) -- [ ] T030 [P] [US3] Extend Operations DB-only test assertions in tests/Feature/Monitoring/OperationsDbOnlyTest.php (assert tabs/KPI labels appear) -- [ ] T031 [P] [US3] Extend Operations tenant isolation coverage in tests/Feature/Monitoring/OperationsTenantScopeTest.php (assert tab views don’t leak) +- [X] T030 [P] [US3] Extend Operations DB-only test assertions in tests/Feature/Monitoring/OperationsDbOnlyTest.php (assert tabs/KPI labels appear) +- [X] T031 [P] [US3] Extend Operations tenant isolation coverage in tests/Feature/Monitoring/OperationsTenantScopeTest.php (assert tab views don’t leak) ### Implementation (US3) -- [ ] T032 [P] [US3] Create Operations KPI header widget in app/Filament/Widgets/Operations/OperationsKpiHeader.php -- [ ] T033 [US3] Add KPIs to the Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php -- [ ] T034 [US3] Implement status tabs (All/Active/Succeeded/Partial/Failed) on Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php -- [ ] T035 [US3] Ensure tab filter logic matches specs/058-tenant-ui-polish/contracts/ui.md by adjusting queries in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php -- [ ] T036 [US3] Implement conditional polling for Operations list (only while active runs exist) by wiring table polling in app/Filament/Resources/OperationRunResource.php and/or app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php -- [ ] T037 [US3] Ensure canonical “View run” links still route to OperationRunResource view pages (no legacy routes) +- [X] T032 [P] [US3] Create Operations KPI header widget in app/Filament/Widgets/Operations/OperationsKpiHeader.php +- [X] T033 [US3] Add KPIs to the Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [X] T034 [US3] Implement status tabs (All/Active/Succeeded/Partial/Failed) on Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [X] T035 [US3] Ensure tab filter logic matches specs/058-tenant-ui-polish/contracts/ui.md by adjusting queries in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [X] T036 [US3] Implement conditional polling for Operations list (only while active runs exist) by wiring table polling in app/Filament/Resources/OperationRunResource.php and/or app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [X] T037 [US3] Ensure canonical “View run” links still route to OperationRunResource view pages (no legacy routes) - Verify existing canonical link helper `App\Support\OperationRunLinks` is used consistently. - If no suitable helper exists for a given surface, add a minimal equivalent and use it everywhere. -- [ ] T042 [US3] Operations terminology sweep (FR-010) +- [X] T042 [US3] Operations terminology sweep (FR-010) - Goal: The UI uses the canonical label “Operations” consistently; no legacy naming remains. - Audit + fix in: - Navigation label(s) @@ -132,9 +132,11 @@ ### Implementation (US3) ## Phase 6: Polish & Cross-Cutting Concerns -- [ ] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty` -- [ ] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green -- [ ] T040 [P] Smoke-check key pages render for a tenant in tests/Feature/Filament/AdminSmokeTest.php (add assertions only if gaps are found) +- [X] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty` +- [X] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green +- [X] T040 [P] Smoke-check key pages render for a tenant in tests/Feature/Filament/AdminSmokeTest.php (add assertions only if gaps are found) +- [X] T043 Refactor KPI headers to StatsOverviewWidget (Inventory + Operations) to match Filament demo tiles and reduce custom Blade drift +- [X] T044 Inventory IA polish: remove Inventory “Overview”, make Items the default entry, rename “Inventory Sync Runs” → “Sync History”, and move “Run Inventory Sync” to Items header actions --- diff --git a/tests/Feature/Filament/InventoryHubDbOnlyTest.php b/tests/Feature/Filament/InventoryHubDbOnlyTest.php new file mode 100644 index 0000000..25b460a --- /dev/null +++ b/tests/Feature/Filament/InventoryHubDbOnlyTest.php @@ -0,0 +1,49 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'display_name' => 'Item A', + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'item-a', + 'platform' => 'windows', + ]); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => str_repeat('a', 64), + 'status' => InventorySyncRun::STATUS_SUCCESS, + ]); + + $this->actingAs($user); + + Bus::fake(); + + assertNoOutboundHttp(function () use ($tenant): void { + $this->get(InventoryItemResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee('Run Inventory Sync') + ->assertSee('Item A'); + + $this->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee(str_repeat('a', 12)); + + $this->get(InventoryCoverage::getUrl(tenant: $tenant)) + ->assertOk() + ->assertSee('Policies'); + }); + + Bus::assertNothingDispatched(); +}); diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php index 2ec5039..eaf2688 100644 --- a/tests/Feature/Filament/InventoryPagesTest.php +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -1,13 +1,16 @@ create(); $user = User::factory()->create(); @@ -15,14 +18,55 @@ $tenant->getKey() => ['role' => 'owner'], ]); + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'display_name' => 'Item A', + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'item-a', + 'platform' => 'windows', + ]); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => str_repeat('a', 64), + 'status' => InventorySyncRun::STATUS_SUCCESS, + ]); + + $itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant); + $syncRunsUrl = InventorySyncRunResource::getUrl('index', tenant: $tenant); + $coverageUrl = InventoryCoverage::getUrl(tenant: $tenant); + + $kpiLabels = [ + 'Total items', + 'Coverage', + 'Last inventory sync', + 'Active ops', + 'Inventory ops', + ]; + $this->actingAs($user) - ->get(InventoryLanding::getUrl(tenant: $tenant)) + ->get($itemsUrl) ->assertOk() - ->assertSee('Run Inventory Sync'); + ->assertSee('Run Inventory Sync') + ->assertSee($syncRunsUrl) + ->assertSee($coverageUrl) + ->assertSee($kpiLabels) + ->assertSee('Item A'); + + $this->actingAs($user) + ->get($syncRunsUrl) + ->assertOk() + ->assertSee($itemsUrl) + ->assertSee($coverageUrl) + ->assertSee($kpiLabels) + ->assertSee(str_repeat('a', 12)); $this->actingAs($user) ->get(InventoryCoverage::getUrl(tenant: $tenant)) ->assertOk() + ->assertSee($itemsUrl) + ->assertSee($syncRunsUrl) + ->assertSee($kpiLabels) ->assertSee('Coverage') ->assertSee('Policies') ->assertSee('Foundations') 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'); }); diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index ddeb0e8..b4e3171 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -1,6 +1,6 @@ defaultSelectionPayload()['policy_types']; - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['policy_types' => $allTypes]) ->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey()); @@ -58,7 +58,7 @@ $allTypes = $sync->defaultSelectionPayload()['policy_types']; $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->mountAction('run_inventory_sync') ->set('mountedActions.0.data.policy_types', $selectedTypes) ->assertActionDataSet(['policy_types' => $selectedTypes]) @@ -85,7 +85,7 @@ $allTypes = $sync->defaultSelectionPayload()['policy_types']; $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: [ 'policy_types' => $selectedTypes, 'include_dependencies' => false, @@ -110,7 +110,7 @@ $allTypes = $sync->defaultSelectionPayload()['policy_types']; $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->mountAction('run_inventory_sync') ->set('mountedActions.0.data.policy_types', $selectedTypes) ->assertActionDataSet(['include_foundations' => true]) @@ -135,7 +135,7 @@ $allTypes = $sync->defaultSelectionPayload()['policy_types']; $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: [ 'policy_types' => $selectedTypes, 'include_foundations' => false, @@ -159,7 +159,7 @@ $sync = app(InventorySyncService::class); $allTypes = $sync->defaultSelectionPayload()['policy_types']; - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) ->assertStatus(403); @@ -196,7 +196,7 @@ 'errors_count' => 0, ]); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]); Queue::assertNothingPushed(); @@ -211,7 +211,7 @@ $this->actingAs($user); Filament::setTenant($tenant, true); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->assertActionHidden('run_inventory_sync'); Queue::assertNothingPushed(); diff --git a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php index 681266a..f1e5d26 100644 --- a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php +++ b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php @@ -1,6 +1,6 @@ defaultSelectionPayload()['policy_types'] ?? []; - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]); $opRun = OperationRun::query() diff --git a/tests/Feature/Monitoring/OperationsDbOnlyTest.php b/tests/Feature/Monitoring/OperationsDbOnlyTest.php index 258dde8..f24ee74 100644 --- a/tests/Feature/Monitoring/OperationsDbOnlyTest.php +++ b/tests/Feature/Monitoring/OperationsDbOnlyTest.php @@ -21,7 +21,16 @@ assertNoOutboundHttp(function () use ($tenant) { $this->get(OperationRunResource::getUrl('index', tenant: $tenant)) - ->assertOk(); + ->assertOk() + ->assertSee('Total Runs (30 days)') + ->assertSee('Active Runs') + ->assertSee('Failed/Partial (7 days)') + ->assertSee('Avg Duration (7 days)') + ->assertSee('All') + ->assertSee('Active') + ->assertSee('Succeeded') + ->assertSee('Partial') + ->assertSee('Failed'); }); Bus::assertNothingDispatched(); diff --git a/tests/Feature/Monitoring/OperationsTenantScopeTest.php b/tests/Feature/Monitoring/OperationsTenantScopeTest.php index 65fd6f0..b6f3c3a 100644 --- a/tests/Feature/Monitoring/OperationsTenantScopeTest.php +++ b/tests/Feature/Monitoring/OperationsTenantScopeTest.php @@ -1,8 +1,11 @@ create(); @@ -39,6 +42,85 @@ ->assertDontSee('TenantB'); }); +it('scopes Monitoring → Operations tabs to the active tenant', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + + $user->tenants()->syncWithoutDetaching([ + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $runActiveA = OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'A-active', + ]); + + $runSucceededA = OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'initiator_name' => 'A-succeeded', + ]); + + $runPartialA = OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'completed', + 'outcome' => 'partially_succeeded', + 'initiator_name' => 'A-partial', + ]); + + $runFailedA = OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'completed', + 'outcome' => 'failed', + 'initiator_name' => 'A-failed', + ]); + + $runActiveB = OperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'B-active', + ]); + + $runFailedB = OperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'type' => 'inventory.sync', + 'status' => 'completed', + 'outcome' => 'failed', + 'initiator_name' => 'B-failed', + ]); + + $tenantA->makeCurrent(); + Filament::setTenant($tenantA, true); + + Livewire::actingAs($user) + ->test(ListOperationRuns::class) + ->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA]) + ->assertCanNotSeeTableRecords([$runActiveB, $runFailedB]) + ->set('activeTab', 'active') + ->assertCanSeeTableRecords([$runActiveA]) + ->assertCanNotSeeTableRecords([$runSucceededA, $runPartialA, $runFailedA, $runActiveB, $runFailedB]) + ->set('activeTab', 'succeeded') + ->assertCanSeeTableRecords([$runSucceededA]) + ->assertCanNotSeeTableRecords([$runActiveA, $runPartialA, $runFailedA, $runActiveB, $runFailedB]) + ->set('activeTab', 'partial') + ->assertCanSeeTableRecords([$runPartialA]) + ->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runFailedA, $runActiveB, $runFailedB]) + ->set('activeTab', 'failed') + ->assertCanSeeTableRecords([$runFailedA]) + ->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runActiveB, $runFailedB]); +}); + it('prevents cross-tenant access to Monitoring → Operations detail', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); diff --git a/tests/Feature/RunStartAuthorizationTest.php b/tests/Feature/RunStartAuthorizationTest.php index cb1b577..83fbb70 100644 --- a/tests/Feature/RunStartAuthorizationTest.php +++ b/tests/Feature/RunStartAuthorizationTest.php @@ -1,6 +1,6 @@ defaultSelectionPayload()['policy_types']; - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) ->assertStatus(403);