diff --git a/app/Filament/Pages/InventoryCoverage.php b/app/Filament/Pages/InventoryCoverage.php index 7b014ff..0fff310 100644 --- a/app/Filament/Pages/InventoryCoverage.php +++ b/app/Filament/Pages/InventoryCoverage.php @@ -14,6 +14,8 @@ class InventoryCoverage extends Page { protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells'; + protected static ?int $navigationSort = 3; + protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static ?string $navigationLabel = 'Coverage'; diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php index 3e6f33d..07c3ef4 100644 --- a/app/Filament/Pages/InventoryLanding.php +++ b/app/Filament/Pages/InventoryLanding.php @@ -4,32 +4,16 @@ 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; -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 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'; @@ -40,229 +24,15 @@ class InventoryLanding extends Page protected string $view = 'filament.pages.inventory-landing'; + public function mount(): void + { + $this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current())); + } + 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); - }), - ]; - } - - 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 538d7bf..3c342dd 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -28,6 +28,8 @@ class InventoryItemResource extends Resource 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'; diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index 6792b35..d37abf9 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -4,7 +4,25 @@ 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 { @@ -16,4 +34,208 @@ protected function getHeaderWidgets(): array 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 1716a8e..5f32f5c 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -27,10 +27,17 @@ class InventorySyncRunResource extends Resource 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/Widgets/Inventory/InventoryKpiHeader.php b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php index 7d8352c..f4128ea 100644 --- a/app/Filament/Widgets/Inventory/InventoryKpiHeader.php +++ b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php @@ -4,21 +4,25 @@ namespace App\Filament\Widgets\Inventory; +use App\Filament\Resources\InventorySyncRunResource; use App\Models\InventoryItem; use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Inventory\CoverageCapabilitiesResolver; +use App\Support\Inventory\InventoryKpiBadges; use App\Support\Inventory\InventoryPolicyTypeMeta; +use App\Support\Inventory\InventorySyncStatusBadge; use Filament\Facades\Filament; -use Filament\Widgets\Widget; +use Filament\Widgets\StatsOverviewWidget; +use Filament\Widgets\StatsOverviewWidget\Stat; +use Illuminate\Support\Facades\Blade; +use Illuminate\Support\HtmlString; -class InventoryKpiHeader extends Widget +class InventoryKpiHeader extends StatsOverviewWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.inventory.inventory-kpi-header'; - protected int|string|array $columnSpan = 'full'; /** @@ -27,23 +31,19 @@ class InventoryKpiHeader extends Widget * - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`) * - dependency capability via `CoverageCapabilitiesResolver` * - * @return array + * @return array */ - protected function getViewData(): array + protected function getStats(): 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, + 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'), ]; } @@ -85,17 +85,49 @@ protected function getViewData(): array ->latest('id') ->first(); - $lastInventorySyncLabel = '—'; + $lastInventorySyncTimeLabel = '—'; + $lastInventorySyncStatusLabel = '—'; + $lastInventorySyncStatusColor = 'gray'; + $lastInventorySyncStatusIcon = 'heroicon-m-clock'; + $lastInventorySyncViewUrl = null; + if ($lastRun instanceof InventorySyncRun) { $timestamp = $lastRun->finished_at ?? $lastRun->started_at; - $lastInventorySyncLabel = trim(sprintf( - '%s%s', - (string) ($lastRun->status ?? '—'), - $timestamp ? ' • '.$timestamp->diffForHumans() : '' - )); + 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() @@ -117,15 +149,14 @@ protected function getViewData(): array } return [ - 'totalItems' => $totalItems, - 'coveragePercent' => $coveragePercent, - 'lastInventorySyncLabel' => $lastInventorySyncLabel, - 'activeOps' => $activeOps, - 'inventoryOps' => $inventoryOps, - 'dependenciesItems' => $dependenciesItems, - 'partialItems' => $partialItems, - 'restorableItems' => $restorableItems, - 'riskItems' => $riskItems, + 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 index 50b02e3..0ba00a8 100644 --- a/app/Filament/Widgets/Operations/OperationsKpiHeader.php +++ b/app/Filament/Widgets/Operations/OperationsKpiHeader.php @@ -11,31 +11,40 @@ use App\Support\OpsUx\ActiveRuns; use Carbon\CarbonInterval; use Filament\Facades\Filament; -use Filament\Widgets\Widget; +use Filament\Widgets\StatsOverviewWidget; +use Filament\Widgets\StatsOverviewWidget\Stat; use Illuminate\Support\Collection; -class OperationsKpiHeader extends Widget +class OperationsKpiHeader extends StatsOverviewWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.operations.operations-kpi-header'; - 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, - 'totalRuns30Days' => 0, - 'activeRuns' => 0, - 'failedOrPartial7Days' => 0, - 'avgDuration7Days' => '—', + 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)', '—'), ]; } @@ -99,11 +108,10 @@ protected function getViewData(): array } return [ - 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, - 'totalRuns30Days' => $totalRuns30Days, - 'activeRuns' => $activeRuns, - 'failedOrPartial7Days' => $failedOrPartial7Days, - 'avgDuration7Days' => $avgDuration7Days, + 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), ]; } 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/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/inventory/inventory-kpi-header.blade.php b/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php deleted file mode 100644 index aa11864..0000000 --- a/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php +++ /dev/null @@ -1,36 +0,0 @@ -
-
-
-
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 deleted file mode 100644 index 3ff4b1c..0000000 --- a/resources/views/filament/widgets/operations/operations-kpi-header.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -
-
-
-
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 cc3fb6d..b00f851 100644 --- a/specs/058-tenant-ui-polish/tasks.md +++ b/specs/058-tenant-ui-polish/tasks.md @@ -61,22 +61,22 @@ ## Phase 4: User Story 2 — Inventory becomes a hub module (Priority: P2) ### Tests (US2) -- [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 +- [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) -- [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) +- [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. -- [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) +- [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. @@ -135,6 +135,8 @@ ## Phase 6: Polish & Cross-Cutting Concerns - [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 index 7e08c0c..25b460a 100644 --- a/tests/Feature/Filament/InventoryHubDbOnlyTest.php +++ b/tests/Feature/Filament/InventoryHubDbOnlyTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use App\Filament\Pages\InventoryCoverage; -use App\Filament\Pages\InventoryLanding; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventorySyncRunResource; use App\Models\InventoryItem; @@ -32,12 +31,9 @@ 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('Run Inventory Sync') ->assertSee('Item A'); $this->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php index 687d3cf..eaf2688 100644 --- a/tests/Feature/Filament/InventoryPagesTest.php +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -1,7 +1,6 @@ create(); $user = User::factory()->create(); @@ -19,11 +18,6 @@ $tenant->getKey() => ['role' => 'owner'], ]); - $this->actingAs($user) - ->get(InventoryLanding::getUrl(tenant: $tenant)) - ->assertOk() - ->assertSee('Run Inventory Sync'); - InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), 'display_name' => 'Item A', @@ -38,7 +32,6 @@ '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); @@ -51,18 +44,10 @@ '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('Run Inventory Sync') ->assertSee($syncRunsUrl) ->assertSee($coverageUrl) ->assertSee($kpiLabels) @@ -71,7 +56,6 @@ $this->actingAs($user) ->get($syncRunsUrl) ->assertOk() - ->assertSee($landingUrl) ->assertSee($itemsUrl) ->assertSee($coverageUrl) ->assertSee($kpiLabels) @@ -80,7 +64,6 @@ $this->actingAs($user) ->get(InventoryCoverage::getUrl(tenant: $tenant)) ->assertOk() - ->assertSee($landingUrl) ->assertSee($itemsUrl) ->assertSee($syncRunsUrl) ->assertSee($kpiLabels) 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/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);