Compare commits

...

2 Commits

Author SHA1 Message Date
Ahmed Darrazi
6924a08226 feat: inventory + operations UI polish 2026-01-22 01:03:59 +01:00
Ahmed Darrazi
8d53ad4c1e feat: Filament-native tenant dashboard widgets 2026-01-22 01:03:41 +01:00
26 changed files with 715 additions and 589 deletions

View File

@ -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';

View File

@ -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());
}
}

View File

@ -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';

View File

@ -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);
}),
];
}
}

View File

@ -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;

View File

@ -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<string, mixed>
* @return array<Stat>
*/
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)),
];
}
}

View File

@ -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,
];
}
}

View File

@ -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<string, mixed>
*/
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<Finding>
*/
private function getQuery(): Builder
{
$tenant = Filament::getTenant();
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
/** @var Collection<int, Finding> $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');
}
}

View File

@ -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<string, mixed>
*/
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<OperationRun>
*/
private function getQuery(): Builder
{
$tenant = Filament::getTenant();
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
/** @var Collection<int, OperationRun> $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',
};
}
}

View File

@ -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<string, mixed>
* @return array<Stat>
*/
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'
<div class="flex items-center gap-2">
<x-filament::badge :color="$badgeColor" size="sm">
{{ $statusLabel }}
</x-filament::badge>
@if ($viewUrl)
<x-filament::link :href="$viewUrl" size="sm">
View run
</x-filament::link>
@endif
</div>
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))),
];
}
}

View File

@ -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<string, mixed>
* @return array<Stat>
*/
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),
];
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use Illuminate\Support\Facades\Blade;
class InventoryKpiBadges
{
public static function coverage(int $restorableCount, int $partialCount): string
{
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge color="success" size="sm">
Restorable {{ $restorableCount }}
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
Partial {{ $partialCount }}
</x-filament::badge>
</div>
BLADE, [
'restorableCount' => $restorableCount,
'partialCount' => $partialCount,
]);
}
public static function inventoryOps(int $dependenciesCount, int $riskCount): string
{
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge color="gray" size="sm">
Dependencies {{ $dependenciesCount }}
</x-filament::badge>
<x-filament::badge color="danger" size="sm">
Risk {{ $riskCount }}
</x-filament::badge>
</div>
BLADE, [
'dependenciesCount' => $dependenciesCount,
'riskCount' => $riskCount,
]);
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use App\Models\InventorySyncRun;
class InventorySyncStatusBadge
{
/**
* @return array{label: string, color: string, icon: string}
*/
public static function for(?string $status): array
{
$status = (string) ($status ?? '');
$label = match ($status) {
InventorySyncRun::STATUS_SUCCESS => '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,
];
}
}

View File

@ -1,28 +0,0 @@
<div
@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"
>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Open drift findings</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $openDriftFindings }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">High severity drift</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $highSeverityDriftFindings }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Active operations</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $activeRuns }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Inventory active</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $inventoryActiveRuns }}</div>
</div>
</div>
</div>

View File

@ -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"
>
<div class="flex flex-col gap-4">
<div class="text-base font-semibold text-gray-950 dark:text-white">Needs Attention</div>
<div class="text-base font-semibold">Needs Attention</div>
@if (count($items) === 0)
<div class="flex flex-col gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300">
Everything looks healthy right now.
</div>
@if (count($items) === 0)
<div class="text-sm text-gray-600 dark:text-gray-300">Nothing urgent right now.</div>
@else
<div class="flex flex-col gap-3">
@foreach ($items as $item)
<a
href="{{ $item['url'] }}"
class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-left transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
</a>
@foreach ($healthyChecks as $check)
<div class="flex items-start gap-3">
<x-filament::icon
icon="heroicon-m-check-circle"
class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
/>
<div class="flex-1">
<div class="text-sm font-medium text-gray-950 dark:text-white">{{ $check['title'] }}</div>
<div class="mt-0.5 text-sm text-gray-600 dark:text-gray-300">{{ $check['body'] }}</div>
<div class="mt-1">
<x-filament::link :href="$check['url']" size="sm">
{{ $check['linkLabel'] }}
</x-filament::link>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
@else
<div class="flex flex-col gap-3">
@foreach ($items as $item)
<a
href="{{ $item['url'] }}"
class="rounded-lg bg-gray-50 p-4 text-left transition hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10"
>
<div class="flex items-start justify-between gap-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
<x-filament::badge :color="$item['badgeColor']" size="sm">
{{ $item['badge'] }}
</x-filament::badge>
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
</a>
@endforeach
</div>
@endif
</div>

View File

@ -1,34 +0,0 @@
<div
@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"
>
<div class="flex items-center justify-between">
<div class="text-base font-semibold text-gray-950 dark:text-white">Recent Drift Findings</div>
</div>
<div class="mt-4">
@if ($findings->isEmpty())
<div class="text-sm text-gray-600 dark:text-gray-300">No drift findings yet.</div>
@else
<div class="flex flex-col gap-2">
@foreach ($findings as $finding)
<div class="rounded-lg border border-gray-200 p-3 dark:border-white/10">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $finding->subject_type }} · {{ $finding->subject_external_id }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $finding->created_at?->diffForHumans() }}
</div>
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Severity: {{ $finding->severity }} · Status: {{ $finding->status }}
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -1,37 +0,0 @@
<div
@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"
>
<div class="flex items-center justify-between">
<div class="text-base font-semibold text-gray-950 dark:text-white">Recent Operations</div>
</div>
<div class="mt-4">
@if ($runs->isEmpty())
<div class="text-sm text-gray-600 dark:text-gray-300">No operations yet.</div>
@else
<div class="flex flex-col gap-2">
@foreach ($runs as $run)
<a
href="{{ $run->getAttribute('view_url') }}"
class="rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5"
>
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $run->getAttribute('type_label') ?? $run->type }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $run->created_at?->diffForHumans() }}
</div>
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Status: {{ $run->status }} · Outcome: {{ $run->outcome }}
</div>
</a>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -1,36 +0,0 @@
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="grid grid-cols-1 gap-4 md:grid-cols-5">
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Total items</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $totalItems }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Coverage</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $coveragePercent }}%</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge size="sm" color="success">Restorable {{ $restorableItems }}</x-filament::badge>
<x-filament::badge size="sm" color="warning">Partial {{ $partialItems }}</x-filament::badge>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Last inventory sync</div>
<div class="text-sm font-medium text-gray-950 dark:text-white">{{ $lastInventorySyncLabel }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Active ops</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $activeOps }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Inventory ops</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $inventoryOps }}</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge size="sm" color="info">Dependencies {{ $dependenciesItems }}</x-filament::badge>
<x-filament::badge size="sm" color="danger">Risk {{ $riskItems }}</x-filament::badge>
</div>
</div>
</div>
</div>

View File

@ -1,28 +0,0 @@
<div
@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"
>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Total Runs (30 days)</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $totalRuns30Days }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Active Runs</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $activeRuns }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Failed/Partial (7 days)</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $failedOrPartial7Days }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Duration (7 days)</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $avgDuration7Days }}</div>
</div>
</div>
</div>

View File

@ -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
---

View File

@ -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))

View File

@ -1,7 +1,6 @@
<?php
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Pages\InventoryLanding;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Models\InventoryItem;
@ -11,7 +10,7 @@
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('inventory landing and coverage pages load for a tenant', function () {
test('inventory hub pages load for a tenant', function () {
$tenant = Tenant::factory()->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)

View File

@ -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');
});

View File

@ -1,6 +1,6 @@
<?php
use App\Filament\Pages\InventoryLanding;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Jobs\RunInventorySyncJob;
use App\Livewire\BulkOperationProgress;
use App\Models\InventorySyncRun;
@ -24,7 +24,7 @@
$sync = app(InventorySyncService::class);
$allTypes = $sync->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();

View File

@ -1,6 +1,6 @@
<?php
use App\Filament\Pages\InventoryLanding;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Jobs\RunInventorySyncJob;
use App\Models\OperationRun;
use App\Services\Graph\GraphClientInterface;
@ -31,7 +31,7 @@
$sync = app(InventorySyncService::class);
$policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? [];
Livewire::test(InventoryLanding::class)
Livewire::test(ListInventoryItems::class)
->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]);
$opRun = OperationRun::query()

View File

@ -1,6 +1,6 @@
<?php
use App\Filament\Pages\InventoryLanding;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
@ -23,7 +23,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);