From b637800ef69c07e00d429a139b5398ccedf96fe3 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 21 Jan 2026 17:46:17 +0100 Subject: [PATCH] feat(058): tenant UI polish (dashboard, inventory hub, operations) --- .dockerignore | 7 + .prettierignore | 4 + .../Clusters/Inventory/InventoryCluster.php | 16 +++ app/Filament/Pages/InventoryCoverage.php | 19 ++- app/Filament/Pages/InventoryLanding.php | 16 ++- .../Resources/InventoryItemResource.php | 15 +- .../Pages/ListInventoryItems.php | 8 ++ .../Resources/InventorySyncRunResource.php | 5 +- .../Pages/ListInventorySyncRuns.php | 8 ++ .../Resources/OperationRunResource.php | 5 - .../Pages/ListOperationRuns.php | 53 +++++++ .../Widgets/Inventory/InventoryKpiHeader.php | 131 ++++++++++++++++++ .../Operations/OperationsKpiHeader.php | 128 +++++++++++++++++ app/Providers/Filament/AdminPanelProvider.php | 1 + .../Inventory/InventoryPolicyTypeMeta.php | 101 ++++++++++++++ .../inventory/inventory-kpi-header.blade.php | 36 +++++ .../operations-kpi-header.blade.php | 28 ++++ specs/058-tenant-ui-polish/tasks.md | 84 +++++------ .../Filament/InventoryHubDbOnlyTest.php | 53 +++++++ tests/Feature/Filament/InventoryPagesTest.php | 61 ++++++++ .../Monitoring/OperationsDbOnlyTest.php | 11 +- .../Monitoring/OperationsTenantScopeTest.php | 82 +++++++++++ 22 files changed, 807 insertions(+), 65 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/InventoryPolicyTypeMeta.php create mode 100644 resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php create mode 100644 resources/views/filament/widgets/operations/operations-kpi-header.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 +41,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 +53,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..3e6f33d 100644 --- a/app/Filament/Pages/InventoryLanding.php +++ b/app/Filament/Pages/InventoryLanding.php @@ -2,8 +2,10 @@ namespace App\Filament\Pages; +use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventorySyncRunResource; +use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Jobs\RunInventorySyncJob; use App\Models\InventorySyncRun; use App\Models\Tenant; @@ -11,6 +13,7 @@ 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; @@ -31,10 +34,19 @@ class InventoryLanding extends Page 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 getHeaderWidgets(): array + { + return [ + InventoryKpiHeader::class, + ]; + } + protected function getHeaderActions(): array { return [ @@ -66,7 +78,7 @@ protected function getHeaderActions(): array }), ]) ->options(function (): array { - return collect(config('tenantpilot.supported_policy_types', [])) + 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 { diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 4898bf6..538d7bf 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,8 @@ class InventoryItemResource extends Resource { protected static ?string $model = InventoryItem::class; + protected static ?string $cluster = InventoryCluster::class; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; @@ -190,8 +194,7 @@ private static function typeMeta(?string $type): array return []; } - return collect(static::allTypeMeta()) - ->firstWhere('type', $type) ?? []; + return InventoryPolicyTypeMeta::metaFor($type); } /** @@ -199,12 +202,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..6792b35 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -3,9 +3,17 @@ namespace App\Filament\Resources\InventoryItemResource\Pages; use App\Filament\Resources\InventoryItemResource; +use App\Filament\Widgets\Inventory\InventoryKpiHeader; use Filament\Resources\Pages\ListRecords; class ListInventoryItems extends ListRecords { protected static string $resource = InventoryItemResource::class; + + protected function getHeaderWidgets(): array + { + return [ + InventoryKpiHeader::class, + ]; + } } diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index 731d120..1716a8e 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,7 +23,9 @@ 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 string|BackedEnum|null $navigationIcon = 'heroicon-o-clock'; 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/Inventory/InventoryKpiHeader.php b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php new file mode 100644 index 0000000..7d8352c --- /dev/null +++ b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php @@ -0,0 +1,131 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'totalItems' => 0, + 'coveragePercent' => 0, + 'lastInventorySyncLabel' => '—', + 'activeOps' => 0, + 'inventoryOps' => 0, + 'dependenciesItems' => 0, + 'partialItems' => 0, + 'restorableItems' => 0, + 'riskItems' => 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(); + + $lastInventorySyncLabel = '—'; + if ($lastRun instanceof InventorySyncRun) { + $timestamp = $lastRun->finished_at ?? $lastRun->started_at; + + $lastInventorySyncLabel = trim(sprintf( + '%s%s', + (string) ($lastRun->status ?? '—'), + $timestamp ? ' • '.$timestamp->diffForHumans() : '' + )); + } + + $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 [ + 'totalItems' => $totalItems, + 'coveragePercent' => $coveragePercent, + 'lastInventorySyncLabel' => $lastInventorySyncLabel, + 'activeOps' => $activeOps, + 'inventoryOps' => $inventoryOps, + 'dependenciesItems' => $dependenciesItems, + 'partialItems' => $partialItems, + 'restorableItems' => $restorableItems, + 'riskItems' => $riskItems, + ]; + } +} diff --git a/app/Filament/Widgets/Operations/OperationsKpiHeader.php b/app/Filament/Widgets/Operations/OperationsKpiHeader.php new file mode 100644 index 0000000..50b02e3 --- /dev/null +++ b/app/Filament/Widgets/Operations/OperationsKpiHeader.php @@ -0,0 +1,128 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'totalRuns30Days' => 0, + 'activeRuns' => 0, + 'failedOrPartial7Days' => 0, + 'avgDuration7Days' => '—', + ]; + } + + $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 [ + 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, + 'totalRuns30Days' => $totalRuns30Days, + 'activeRuns' => $activeRuns, + 'failedOrPartial7Days' => $failedOrPartial7Days, + 'avgDuration7Days' => $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/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/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php b/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php new file mode 100644 index 0000000..aa11864 --- /dev/null +++ b/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php @@ -0,0 +1,36 @@ +
+
+
+
Total items
+
{{ $totalItems }}
+
+ +
+
Coverage
+
{{ $coveragePercent }}%
+
+ Restorable {{ $restorableItems }} + Partial {{ $partialItems }} +
+
+ +
+
Last inventory sync
+
{{ $lastInventorySyncLabel }}
+
+ +
+
Active ops
+
{{ $activeOps }}
+
+ +
+
Inventory ops
+
{{ $inventoryOps }}
+
+ Dependencies {{ $dependenciesItems }} + Risk {{ $riskItems }} +
+
+
+
diff --git a/resources/views/filament/widgets/operations/operations-kpi-header.blade.php b/resources/views/filament/widgets/operations/operations-kpi-header.blade.php new file mode 100644 index 0000000..3ff4b1c --- /dev/null +++ b/resources/views/filament/widgets/operations/operations-kpi-header.blade.php @@ -0,0 +1,28 @@ +
+
+
+
Total Runs (30 days)
+
{{ $totalRuns30Days }}
+
+ +
+
Active Runs
+
{{ $activeRuns }}
+
+ +
+
Failed/Partial (7 days)
+
{{ $failedOrPartial7Days }}
+
+ +
+
Avg Duration (7 days)
+
{{ $avgDuration7Days }}
+
+
+
diff --git a/specs/058-tenant-ui-polish/tasks.md b/specs/058-tenant-ui-polish/tasks.md index c4ba39c..cc3fb6d 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,9 @@ ### 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) --- diff --git a/tests/Feature/Filament/InventoryHubDbOnlyTest.php b/tests/Feature/Filament/InventoryHubDbOnlyTest.php new file mode 100644 index 0000000..7e08c0c --- /dev/null +++ b/tests/Feature/Filament/InventoryHubDbOnlyTest.php @@ -0,0 +1,53 @@ +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(InventoryLanding::getUrl(tenant: $tenant)) + ->assertOk() + ->assertSee('Run Inventory Sync'); + + $this->get(InventoryItemResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->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..687d3cf 100644 --- a/tests/Feature/Filament/InventoryPagesTest.php +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -2,6 +2,10 @@ use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryLanding; +use App\Filament\Resources\InventoryItemResource; +use App\Filament\Resources\InventorySyncRunResource; +use App\Models\InventoryItem; +use App\Models\InventorySyncRun; use App\Models\Tenant; use App\Models\User; @@ -20,9 +24,66 @@ ->assertOk() ->assertSee('Run Inventory Sync'); + 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, + ]); + + $landingUrl = InventoryLanding::getUrl(tenant: $tenant); + $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($landingUrl) + ->assertOk() + ->assertSee($itemsUrl) + ->assertSee($syncRunsUrl) + ->assertSee($coverageUrl) + ->assertSee($kpiLabels); + + $this->actingAs($user) + ->get($itemsUrl) + ->assertOk() + ->assertSee($landingUrl) + ->assertSee($syncRunsUrl) + ->assertSee($coverageUrl) + ->assertSee($kpiLabels) + ->assertSee('Item A'); + + $this->actingAs($user) + ->get($syncRunsUrl) + ->assertOk() + ->assertSee($landingUrl) + ->assertSee($itemsUrl) + ->assertSee($coverageUrl) + ->assertSee($kpiLabels) + ->assertSee(str_repeat('a', 12)); + $this->actingAs($user) ->get(InventoryCoverage::getUrl(tenant: $tenant)) ->assertOk() + ->assertSee($landingUrl) + ->assertSee($itemsUrl) + ->assertSee($syncRunsUrl) + ->assertSee($kpiLabels) ->assertSee('Coverage') ->assertSee('Policies') ->assertSee('Foundations') 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();