058-tenant-ui-polish #70
@ -1,14 +1,21 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
.git/
|
.git/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
public/build/
|
public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
public/storage/
|
||||||
|
storage/framework/
|
||||||
|
storage/logs/
|
||||||
storage/debugbar/
|
storage/debugbar/
|
||||||
storage/*.key
|
storage/*.key
|
||||||
/references/
|
/references/
|
||||||
|
|||||||
@ -3,7 +3,11 @@ dist/
|
|||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
public/hot/
|
public/hot/
|
||||||
|
public/storage/
|
||||||
coverage/
|
coverage/
|
||||||
|
vendor/
|
||||||
|
storage/
|
||||||
|
bootstrap/cache/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
16
app/Filament/Clusters/Inventory/InventoryCluster.php
Normal file
16
app/Filament/Clusters/Inventory/InventoryCluster.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\Inventory;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Clusters\Cluster;
|
||||||
|
use Filament\Pages\Enums\SubNavigationPosition;
|
||||||
|
|
||||||
|
class InventoryCluster extends Cluster
|
||||||
|
{
|
||||||
|
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||||
|
}
|
||||||
@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||||
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -11,12 +14,23 @@ class InventoryCoverage extends Page
|
|||||||
{
|
{
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
|
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|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Coverage';
|
protected static ?string $navigationLabel = 'Coverage';
|
||||||
|
|
||||||
|
protected static ?string $cluster = InventoryCluster::class;
|
||||||
|
|
||||||
protected string $view = 'filament.pages.inventory-coverage';
|
protected string $view = 'filament.pages.inventory-coverage';
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
InventoryKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, array<string, mixed>>
|
* @var array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@ -29,12 +43,9 @@ class InventoryCoverage extends Page
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$policyTypes = config('tenantpilot.supported_policy_types', []);
|
|
||||||
$foundationTypes = config('tenantpilot.foundation_types', []);
|
|
||||||
|
|
||||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||||
|
|
||||||
$this->supportedPolicyTypes = collect(is_array($policyTypes) ? $policyTypes : [])
|
$this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported())
|
||||||
->map(function (array $row) use ($resolver): array {
|
->map(function (array $row) use ($resolver): array {
|
||||||
$type = (string) ($row['type'] ?? '');
|
$type = (string) ($row['type'] ?? '');
|
||||||
|
|
||||||
@ -44,7 +55,7 @@ public function mount(): void
|
|||||||
})
|
})
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$this->foundationTypes = collect(is_array($foundationTypes) ? $foundationTypes : [])
|
$this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
|
||||||
->map(function (array $row): array {
|
->map(function (array $row): array {
|
||||||
return array_merge($row, [
|
return array_merge($row, [
|
||||||
'dependencies' => false,
|
'dependencies' => false,
|
||||||
|
|||||||
@ -2,255 +2,37 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
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\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
|
||||||
use App\Services\Inventory\InventorySyncService;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
||||||
use BackedEnum;
|
use 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\Pages\Page;
|
||||||
use Filament\Support\Enums\Size;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class InventoryLanding extends Page
|
class InventoryLanding extends Page
|
||||||
{
|
{
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Inventory';
|
protected static ?string $navigationLabel = 'Overview';
|
||||||
|
|
||||||
|
protected static ?string $cluster = InventoryCluster::class;
|
||||||
|
|
||||||
protected string $view = 'filament.pages.inventory-landing';
|
protected string $view = 'filament.pages.inventory-landing';
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current()));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('run_inventory_sync')
|
InventoryKpiHeader::class,
|
||||||
->label('Run Inventory Sync')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('warning')
|
|
||||||
->form([
|
|
||||||
Select::make('policy_types')
|
|
||||||
->label('Policy types')
|
|
||||||
->multiple()
|
|
||||||
->searchable()
|
|
||||||
->preload()
|
|
||||||
->native(false)
|
|
||||||
->hintActions([
|
|
||||||
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
|
|
||||||
->label('Select all')
|
|
||||||
->link()
|
|
||||||
->size(Size::Small)
|
|
||||||
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
|
|
||||||
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
|
|
||||||
}),
|
|
||||||
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
|
|
||||||
->label('Clear')
|
|
||||||
->link()
|
|
||||||
->size(Size::Small)
|
|
||||||
->action(function () use ($component): void {
|
|
||||||
$component->state([]);
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->options(function (): array {
|
|
||||||
return collect(config('tenantpilot.supported_policy_types', []))
|
|
||||||
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
|
|
||||||
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
|
|
||||||
->mapWithKeys(function ($items, string $category): array {
|
|
||||||
$options = collect($items)
|
|
||||||
->mapWithKeys(function (array $meta): array {
|
|
||||||
$type = (string) $meta['type'];
|
|
||||||
$label = (string) ($meta['label'] ?? $type);
|
|
||||||
$platform = (string) ($meta['platform'] ?? 'all');
|
|
||||||
|
|
||||||
return [$type => "{$label} • {$platform}"];
|
|
||||||
})
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return [$category => $options];
|
|
||||||
})
|
|
||||||
->all();
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
Toggle::make('include_foundations')
|
|
||||||
->label('Include foundation types')
|
|
||||||
->helperText('Include scope tags, assignment filters, and notification templates.')
|
|
||||||
->default(true)
|
|
||||||
->dehydrated()
|
|
||||||
->rules(['boolean'])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Toggle::make('include_dependencies')
|
|
||||||
->label('Include dependencies')
|
|
||||||
->helperText('Include dependency extraction where supported.')
|
|
||||||
->default(true)
|
|
||||||
->dehydrated()
|
|
||||||
->rules(['boolean'])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Hidden::make('tenant_id')
|
|
||||||
->default(fn (): ?string => Tenant::current()?->getKey())
|
|
||||||
->dehydrated(),
|
|
||||||
])
|
|
||||||
->visible(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->canSyncTenant(Tenant::current());
|
|
||||||
})
|
|
||||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403, 'Not allowed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canSyncTenant($tenant)) {
|
|
||||||
abort(403, 'Not allowed');
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestedTenantId = $data['tenant_id'] ?? null;
|
|
||||||
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Not allowed')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
abort(403, 'Not allowed');
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
|
||||||
if (array_key_exists('policy_types', $data)) {
|
|
||||||
$selectionPayload['policy_types'] = $data['policy_types'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('include_foundations', $data)) {
|
|
||||||
$selectionPayload['include_foundations'] = (bool) $data['include_foundations'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('include_dependencies', $data)) {
|
|
||||||
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
|
|
||||||
}
|
|
||||||
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'inventory.sync',
|
|
||||||
inputs: $computed['selection'],
|
|
||||||
initiator: $user
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Inventory sync already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View Run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
|
|
||||||
$existing = InventorySyncRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('selection_hash', $computed['selection_hash'])
|
|
||||||
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
|
|
||||||
if ($existing instanceof InventorySyncRun) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Inventory sync already active')
|
|
||||||
->body('A matching inventory sync run is already pending or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View Run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
|
|
||||||
|
|
||||||
$policyTypes = $computed['selection']['policy_types'] ?? [];
|
|
||||||
if (! is_array($policyTypes)) {
|
|
||||||
$policyTypes = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'inventory.sync.dispatched',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'inventory_sync_run_id' => $run->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $user->id,
|
|
||||||
actorEmail: $user->email,
|
|
||||||
actorName: $user->name,
|
|
||||||
resourceType: 'inventory_sync_run',
|
|
||||||
resourceId: (string) $run->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
|
|
||||||
RunInventorySyncJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
inventorySyncRunId: (int) $run->id,
|
|
||||||
operationRun: $opRun
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Inventory\DependencyQueryService;
|
use App\Services\Inventory\DependencyQueryService;
|
||||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||||
use App\Support\Enums\RelationshipType;
|
use App\Support\Enums\RelationshipType;
|
||||||
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
@ -24,6 +26,10 @@ class InventoryItemResource extends Resource
|
|||||||
{
|
{
|
||||||
protected static ?string $model = InventoryItem::class;
|
protected static ?string $model = InventoryItem::class;
|
||||||
|
|
||||||
|
protected static ?string $cluster = InventoryCluster::class;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
@ -190,8 +196,7 @@ private static function typeMeta(?string $type): array
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect(static::allTypeMeta())
|
return InventoryPolicyTypeMeta::metaFor($type);
|
||||||
->firstWhere('type', $type) ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -199,12 +204,6 @@ private static function typeMeta(?string $type): array
|
|||||||
*/
|
*/
|
||||||
private static function allTypeMeta(): array
|
private static function allTypeMeta(): array
|
||||||
{
|
{
|
||||||
$supported = config('tenantpilot.supported_policy_types', []);
|
return InventoryPolicyTypeMeta::all();
|
||||||
$foundations = config('tenantpilot.foundation_types', []);
|
|
||||||
|
|
||||||
return array_merge(
|
|
||||||
is_array($supported) ? $supported : [],
|
|
||||||
is_array($foundations) ? $foundations : [],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,239 @@
|
|||||||
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
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\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Support\Enums\Size;
|
||||||
|
|
||||||
class ListInventoryItems extends ListRecords
|
class ListInventoryItems extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = InventoryItemResource::class;
|
protected static string $resource = InventoryItemResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
InventoryKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('run_inventory_sync')
|
||||||
|
->label('Run Inventory Sync')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->form([
|
||||||
|
Select::make('policy_types')
|
||||||
|
->label('Policy types')
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->native(false)
|
||||||
|
->hintActions([
|
||||||
|
fn (Select $component): HintAction => HintAction::make('select_all_policy_types')
|
||||||
|
->label('Select all')
|
||||||
|
->link()
|
||||||
|
->size(Size::Small)
|
||||||
|
->action(function (InventorySyncService $inventorySyncService) use ($component): void {
|
||||||
|
$component->state($inventorySyncService->defaultSelectionPayload()['policy_types']);
|
||||||
|
}),
|
||||||
|
fn (Select $component): HintAction => HintAction::make('clear_policy_types')
|
||||||
|
->label('Clear')
|
||||||
|
->link()
|
||||||
|
->size(Size::Small)
|
||||||
|
->action(function () use ($component): void {
|
||||||
|
$component->state([]);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->options(function (): array {
|
||||||
|
return collect(InventoryPolicyTypeMeta::supported())
|
||||||
|
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
|
||||||
|
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
|
||||||
|
->mapWithKeys(function ($items, string $category): array {
|
||||||
|
$options = collect($items)
|
||||||
|
->mapWithKeys(function (array $meta): array {
|
||||||
|
$type = (string) $meta['type'];
|
||||||
|
$label = (string) ($meta['label'] ?? $type);
|
||||||
|
$platform = (string) ($meta['platform'] ?? 'all');
|
||||||
|
|
||||||
|
return [$type => "{$label} • {$platform}"];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [$category => $options];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->columnSpanFull(),
|
||||||
|
Toggle::make('include_foundations')
|
||||||
|
->label('Include foundation types')
|
||||||
|
->helperText('Include scope tags, assignment filters, and notification templates.')
|
||||||
|
->default(true)
|
||||||
|
->dehydrated()
|
||||||
|
->rules(['boolean'])
|
||||||
|
->columnSpanFull(),
|
||||||
|
Toggle::make('include_dependencies')
|
||||||
|
->label('Include dependencies')
|
||||||
|
->helperText('Include dependency extraction where supported.')
|
||||||
|
->default(true)
|
||||||
|
->dehydrated()
|
||||||
|
->rules(['boolean'])
|
||||||
|
->columnSpanFull(),
|
||||||
|
Hidden::make('tenant_id')
|
||||||
|
->default(fn (): ?string => Tenant::current()?->getKey())
|
||||||
|
->dehydrated(),
|
||||||
|
])
|
||||||
|
->visible(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canSyncTenant(Tenant::current());
|
||||||
|
})
|
||||||
|
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canSyncTenant($tenant)) {
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestedTenantId = $data['tenant_id'] ?? null;
|
||||||
|
if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Not allowed')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
abort(403, 'Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
||||||
|
if (array_key_exists('policy_types', $data)) {
|
||||||
|
$selectionPayload['policy_types'] = $data['policy_types'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('include_foundations', $data)) {
|
||||||
|
$selectionPayload['include_foundations'] = (bool) $data['include_foundations'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('include_dependencies', $data)) {
|
||||||
|
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
|
||||||
|
}
|
||||||
|
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'inventory.sync',
|
||||||
|
inputs: $computed['selection'],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Inventory sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
|
||||||
|
$existing = InventorySyncRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('selection_hash', $computed['selection_hash'])
|
||||||
|
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
|
||||||
|
if ($existing instanceof InventorySyncRun) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Inventory sync already active')
|
||||||
|
->body('A matching inventory sync run is already pending or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
|
||||||
|
|
||||||
|
$policyTypes = $computed['selection']['policy_types'] ?? [];
|
||||||
|
if (! is_array($policyTypes)) {
|
||||||
|
$policyTypes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'inventory.sync.dispatched',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'inventory_sync_run_id' => $run->id,
|
||||||
|
'selection_hash' => $run->selection_hash,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $user->id,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
resourceType: 'inventory_sync_run',
|
||||||
|
resourceId: (string) $run->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
|
||||||
|
RunInventorySyncJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
inventorySyncRunId: (int) $run->id,
|
||||||
|
operationRun: $opRun
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -22,12 +23,21 @@ class InventorySyncRunResource extends Resource
|
|||||||
{
|
{
|
||||||
protected static ?string $model = InventorySyncRun::class;
|
protected static ?string $model = InventorySyncRun::class;
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = true;
|
||||||
|
|
||||||
|
protected static ?string $cluster = InventoryCluster::class;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return 'Sync History';
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema;
|
return $schema;
|
||||||
|
|||||||
@ -3,9 +3,17 @@
|
|||||||
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
|
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\InventorySyncRunResource;
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListInventorySyncRuns extends ListRecords
|
class ListInventorySyncRuns extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = InventorySyncRunResource::class;
|
protected static string $resource = InventorySyncRunResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
InventoryKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -144,11 +144,6 @@ public static function table(Table $table): Table
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->modifyQueryUsing(function (Builder $query): Builder {
|
|
||||||
$tenantId = Tenant::current()?->getKey();
|
|
||||||
|
|
||||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
|
||||||
})
|
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
|
|||||||
@ -3,9 +3,62 @@
|
|||||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
namespace App\Filament\Resources\OperationRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class ListOperationRuns extends ListRecords
|
class ListOperationRuns extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = OperationRunResource::class;
|
protected static string $resource = OperationRunResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
OperationsKpiHeader::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Tab>
|
||||||
|
*/
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'all' => Tab::make(),
|
||||||
|
'active' => Tab::make()
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
])),
|
||||||
|
'succeeded' => Tab::make()
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Succeeded->value)),
|
||||||
|
'partial' => Tab::make()
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::PartiallySucceeded->value)),
|
||||||
|
'failed' => Tab::make()
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Failed->value)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getTablePollingInterval(): ?string
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,35 +4,46 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use Filament\Facades\Filament;
|
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 static bool $isLazy = false;
|
||||||
|
|
||||||
protected string $view = 'filament.widgets.dashboard.dashboard-kpis';
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
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();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return [
|
return [
|
||||||
'pollingInterval' => null,
|
Stat::make('Open drift findings', 0),
|
||||||
'openDriftFindings' => 0,
|
Stat::make('High severity drift', 0),
|
||||||
'highSeverityDriftFindings' => 0,
|
Stat::make('Active operations', 0),
|
||||||
'activeRuns' => 0,
|
Stat::make('Inventory active', 0),
|
||||||
'inventoryActiveRuns' => 0,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,11 +74,17 @@ protected function getViewData(): array
|
|||||||
->count();
|
->count();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
Stat::make('Open drift findings', $openDriftFindings)
|
||||||
'openDriftFindings' => $openDriftFindings,
|
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
||||||
'highSeverityDriftFindings' => $highSeverityDriftFindings,
|
Stat::make('High severity drift', $highSeverityDriftFindings)
|
||||||
'activeRuns' => $activeRuns,
|
->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray')
|
||||||
'inventoryActiveRuns' => $inventoryActiveRuns,
|
->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)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ protected function getViewData(): array
|
|||||||
return [
|
return [
|
||||||
'pollingInterval' => null,
|
'pollingInterval' => null,
|
||||||
'items' => [],
|
'items' => [],
|
||||||
|
'healthyChecks' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +51,8 @@ protected function getViewData(): array
|
|||||||
'title' => 'High severity drift findings',
|
'title' => 'High severity drift findings',
|
||||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
'body' => "{$highSeverityCount} finding(s) need review.",
|
||||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
||||||
|
'badge' => 'Drift',
|
||||||
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +70,8 @@ protected function getViewData(): array
|
|||||||
'title' => 'No drift scan yet',
|
'title' => 'No drift scan yet',
|
||||||
'body' => 'Generate drift after you have at least two successful inventory runs.',
|
'body' => 'Generate drift after you have at least two successful inventory runs.',
|
||||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||||
|
'badge' => 'Drift',
|
||||||
|
'badgeColor' => 'warning',
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
||||||
@ -76,6 +81,8 @@ protected function getViewData(): array
|
|||||||
'title' => 'Drift stale',
|
'title' => 'Drift stale',
|
||||||
'body' => 'Last drift scan is older than 7 days.',
|
'body' => 'Last drift scan is older than 7 days.',
|
||||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||||
|
'badge' => 'Drift',
|
||||||
|
'badgeColor' => 'warning',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,6 +100,8 @@ protected function getViewData(): array
|
|||||||
'title' => 'Drift generation failed',
|
'title' => 'Drift generation failed',
|
||||||
'body' => 'Investigate the latest failed run.',
|
'body' => 'Investigate the latest failed run.',
|
||||||
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
|
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
|
||||||
|
'badge' => 'Operations',
|
||||||
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,12 +115,44 @@ protected function getViewData(): array
|
|||||||
'title' => 'Operations in progress',
|
'title' => 'Operations in progress',
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
'body' => "{$activeRuns} run(s) are active.",
|
||||||
'url' => OperationRunLinks::index($tenant),
|
'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 [
|
return [
|
||||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
|
'healthyChecks' => $healthyChecks,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,46 +4,80 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Illuminate\Support\Collection;
|
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 static bool $isLazy = false;
|
||||||
|
|
||||||
protected string $view = 'filament.widgets.dashboard.recent-drift-findings';
|
public function table(Table $table): Table
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
return $table
|
||||||
return [
|
->heading('Recent Drift Findings')
|
||||||
'pollingInterval' => null,
|
->query($this->getQuery())
|
||||||
'findings' => collect(),
|
->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 */
|
return Finding::query()
|
||||||
$findings = Finding::query()
|
->addSelect([
|
||||||
->where('tenant_id', $tenantId)
|
'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)
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
->latest('created_at')
|
->latest('created_at');
|
||||||
->limit(10)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
|
||||||
'findings' => $findings,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,48 +10,85 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Illuminate\Support\Collection;
|
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 static bool $isLazy = false;
|
||||||
|
|
||||||
protected string $view = 'filament.widgets.dashboard.recent-operations';
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
/**
|
public function table(Table $table): Table
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
protected function getViewData(): array
|
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
return $table
|
||||||
return [
|
->heading('Recent Operations')
|
||||||
'pollingInterval' => null,
|
->query($this->getQuery())
|
||||||
'runs' => collect(),
|
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
|
||||||
'viewRunBaseUrl' => 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 */
|
return OperationRun::query()
|
||||||
$runs = OperationRun::query()
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
->where('tenant_id', $tenantId)
|
->latest('created_at');
|
||||||
->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 [
|
private function statusColor(?string $status): string
|
||||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
{
|
||||||
'runs' => $runs,
|
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',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
app/Filament/Widgets/Inventory/InventoryKpiHeader.php
Normal file
162
app/Filament/Widgets/Inventory/InventoryKpiHeader.php
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
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\StatsOverviewWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
|
class InventoryKpiHeader extends StatsOverviewWidget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventory KPI aggregation source-of-truth:
|
||||||
|
* - `inventory_items.policy_type`
|
||||||
|
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
|
||||||
|
* - dependency capability via `CoverageCapabilitiesResolver`
|
||||||
|
*
|
||||||
|
* @return array<Stat>
|
||||||
|
*/
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
Stat::make('Total items', 0),
|
||||||
|
Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'),
|
||||||
|
Stat::make('Last inventory sync', '—'),
|
||||||
|
Stat::make('Active ops', 0),
|
||||||
|
Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
/** @var array<string, int> $countsByPolicyType */
|
||||||
|
$countsByPolicyType = InventoryItem::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->selectRaw('policy_type, COUNT(*) as aggregate')
|
||||||
|
->groupBy('policy_type')
|
||||||
|
->pluck('aggregate', 'policy_type')
|
||||||
|
->map(fn ($value): int => (int) $value)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$totalItems = array_sum($countsByPolicyType);
|
||||||
|
|
||||||
|
$restorableItems = 0;
|
||||||
|
$partialItems = 0;
|
||||||
|
$riskItems = 0;
|
||||||
|
|
||||||
|
foreach ($countsByPolicyType as $policyType => $count) {
|
||||||
|
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
|
||||||
|
$restorableItems += $count;
|
||||||
|
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
|
||||||
|
$partialItems += $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
|
||||||
|
$riskItems += $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$coveragePercent = $totalItems > 0
|
||||||
|
? (int) round(($restorableItems / $totalItems) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$lastRun = InventorySyncRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$lastInventorySyncTimeLabel = '—';
|
||||||
|
$lastInventorySyncStatusLabel = '—';
|
||||||
|
$lastInventorySyncStatusColor = 'gray';
|
||||||
|
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
|
||||||
|
$lastInventorySyncViewUrl = null;
|
||||||
|
|
||||||
|
if ($lastRun instanceof InventorySyncRun) {
|
||||||
|
$timestamp = $lastRun->finished_at ?? $lastRun->started_at;
|
||||||
|
|
||||||
|
if ($timestamp) {
|
||||||
|
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($lastRun->status ?? '');
|
||||||
|
|
||||||
|
$badge = InventorySyncStatusBadge::for($status);
|
||||||
|
$lastInventorySyncStatusLabel = $badge['label'];
|
||||||
|
$lastInventorySyncStatusColor = $badge['color'];
|
||||||
|
$lastInventorySyncStatusIcon = $badge['icon'];
|
||||||
|
|
||||||
|
$lastInventorySyncViewUrl = InventorySyncRunResource::getUrl('view', ['record' => $lastRun], tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
$badgeColor = $lastInventorySyncStatusColor;
|
||||||
|
|
||||||
|
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
|
||||||
|
<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()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$inventoryOps = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('type', 'inventory.sync')
|
||||||
|
->active()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||||
|
|
||||||
|
$dependenciesItems = 0;
|
||||||
|
foreach ($countsByPolicyType as $policyType => $count) {
|
||||||
|
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
|
||||||
|
$dependenciesItems += $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('Total items', $totalItems),
|
||||||
|
Stat::make('Coverage', $coveragePercent.'%')
|
||||||
|
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
|
||||||
|
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
|
||||||
|
->description(new HtmlString($lastInventorySyncDescription)),
|
||||||
|
Stat::make('Active ops', $activeOps),
|
||||||
|
Stat::make('Inventory ops', $inventoryOps)
|
||||||
|
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/Filament/Widgets/Operations/OperationsKpiHeader.php
Normal file
136
app/Filament/Widgets/Operations/OperationsKpiHeader.php
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Operations;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class OperationsKpiHeader extends StatsOverviewWidget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
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<Stat>
|
||||||
|
*/
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
Stat::make('Total Runs (30 days)', 0),
|
||||||
|
Stat::make('Active Runs', 0),
|
||||||
|
Stat::make('Failed/Partial (7 days)', 0),
|
||||||
|
Stat::make('Avg Duration (7 days)', '—'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
$totalRuns30Days = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$activeRuns = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$failedOrPartial7Days = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->whereIn('outcome', [
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
])
|
||||||
|
->where('completed_at', '>=', now()->subDays(7))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
/** @var Collection<int, OperationRun> $recentCompletedRuns */
|
||||||
|
$recentCompletedRuns = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->whereNotNull('started_at')
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->where('completed_at', '>=', now()->subDays(7))
|
||||||
|
->latest('id')
|
||||||
|
->limit(200)
|
||||||
|
->get(['started_at', 'completed_at']);
|
||||||
|
|
||||||
|
$durations = $recentCompletedRuns
|
||||||
|
->map(function (OperationRun $run): ?int {
|
||||||
|
if (! $run->started_at || ! $run->completed_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = $run->completed_at->diffInSeconds($run->started_at);
|
||||||
|
|
||||||
|
if (is_int($seconds)) {
|
||||||
|
return $seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round((float) $seconds);
|
||||||
|
})
|
||||||
|
->filter(fn (?int $seconds): bool => is_int($seconds) && $seconds > 0)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$avgDuration7Days = '—';
|
||||||
|
if ($durations->isNotEmpty()) {
|
||||||
|
$avgDurationSeconds = (int) round($durations->avg() ?? 0);
|
||||||
|
$avgDuration7Days = self::formatDurationSeconds($avgDurationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('Total Runs (30 days)', $totalRuns30Days),
|
||||||
|
Stat::make('Active Runs', $activeRuns),
|
||||||
|
Stat::make('Failed/Partial (7 days)', $failedOrPartial7Days),
|
||||||
|
Stat::make('Avg Duration (7 days)', $avgDuration7Days),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatDurationSeconds(int $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds <= 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seconds < 60) {
|
||||||
|
return $seconds.'s';
|
||||||
|
}
|
||||||
|
|
||||||
|
$interval = CarbonInterval::seconds($seconds)->cascade();
|
||||||
|
|
||||||
|
if ($seconds < 3600) {
|
||||||
|
return sprintf('%dm %ds', $interval->minutes, $interval->seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%dh %dm', $interval->hours, $interval->minutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,6 +48,7 @@ public function panel(Panel $panel): Panel
|
|||||||
? view('livewire.bulk-operation-progress-wrapper')->render()
|
? view('livewire.bulk-operation-progress-wrapper')->render()
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
|
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
|
|||||||
46
app/Support/Inventory/InventoryKpiBadges.php
Normal file
46
app/Support/Inventory/InventoryKpiBadges.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/Support/Inventory/InventoryPolicyTypeMeta.php
Normal file
101
app/Support/Inventory/InventoryPolicyTypeMeta.php
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Inventory;
|
||||||
|
|
||||||
|
class InventoryPolicyTypeMeta
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Canonical inventory policy-type metadata source-of-truth.
|
||||||
|
*
|
||||||
|
* These definitions are used for UI classification (restore/risk) and KPI aggregation.
|
||||||
|
* The authoritative inputs are:
|
||||||
|
* - `inventory_items.policy_type`
|
||||||
|
* - `config('tenantpilot.supported_policy_types')` and `config('tenantpilot.foundation_types')`
|
||||||
|
* meta fields, especially: `restore`, `risk`.
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
static::supported(),
|
||||||
|
static::foundations(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function supported(): array
|
||||||
|
{
|
||||||
|
$supported = config('tenantpilot.supported_policy_types', []);
|
||||||
|
|
||||||
|
return is_array($supported) ? $supported : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function foundations(): array
|
||||||
|
{
|
||||||
|
$foundations = config('tenantpilot.foundation_types', []);
|
||||||
|
|
||||||
|
return is_array($foundations) ? $foundations : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function byType(): array
|
||||||
|
{
|
||||||
|
return collect(static::all())
|
||||||
|
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
||||||
|
->keyBy(fn (array $row): string => (string) $row['type'])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function metaFor(?string $type): array
|
||||||
|
{
|
||||||
|
if (! filled($type)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::byType()[(string) $type] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function restoreMode(?string $type): ?string
|
||||||
|
{
|
||||||
|
$restore = static::metaFor($type)['restore'] ?? null;
|
||||||
|
|
||||||
|
return is_string($restore) ? $restore : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function riskLevel(?string $type): ?string
|
||||||
|
{
|
||||||
|
$risk = static::metaFor($type)['risk'] ?? null;
|
||||||
|
|
||||||
|
return is_string($risk) ? $risk : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isRestorable(?string $type): bool
|
||||||
|
{
|
||||||
|
return static::restoreMode($type) === 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isPartial(?string $type): bool
|
||||||
|
{
|
||||||
|
$restore = static::restoreMode($type);
|
||||||
|
|
||||||
|
return filled($restore) && $restore !== 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isHighRisk(?string $type): bool
|
||||||
|
{
|
||||||
|
$risk = static::riskLevel($type);
|
||||||
|
|
||||||
|
return is_string($risk) && str_contains($risk, 'high');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Support/Inventory/InventorySyncStatusBadge.php
Normal file
55
app/Support/Inventory/InventorySyncStatusBadge.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
@ -2,25 +2,54 @@
|
|||||||
@if ($pollingInterval)
|
@if ($pollingInterval)
|
||||||
wire:poll.{{ $pollingInterval }}
|
wire:poll.{{ $pollingInterval }}
|
||||||
@endif
|
@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">Needs Attention</div>
|
||||||
<div class="text-base font-semibold text-gray-950 dark:text-white">Needs Attention</div>
|
|
||||||
|
|
||||||
@if (count($items) === 0)
|
@if (count($items) === 0)
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">Nothing urgent right now.</div>
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Everything looks healthy right now.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@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>
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@foreach ($items as $item)
|
@foreach ($items as $item)
|
||||||
<a
|
<a
|
||||||
href="{{ $item['url'] }}"
|
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"
|
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>
|
<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>
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -12,15 +12,15 @@ # Tasks: Tenant UI Polish (Dashboard + Inventory Hub + Operations)
|
|||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
- [ ] T001 Confirm feature inputs exist: specs/058-tenant-ui-polish/spec.md, specs/058-tenant-ui-polish/plan.md
|
- [X] T001 Confirm feature inputs exist: specs/058-tenant-ui-polish/spec.md, specs/058-tenant-ui-polish/plan.md
|
||||||
- [ ] T002 Confirm Phase 0/1 artifacts exist: specs/058-tenant-ui-polish/research.md, specs/058-tenant-ui-polish/data-model.md, specs/058-tenant-ui-polish/contracts/ui.md, specs/058-tenant-ui-polish/contracts/polling.md, specs/058-tenant-ui-polish/quickstart.md
|
- [X] T002 Confirm Phase 0/1 artifacts exist: specs/058-tenant-ui-polish/research.md, specs/058-tenant-ui-polish/data-model.md, specs/058-tenant-ui-polish/contracts/ui.md, specs/058-tenant-ui-polish/contracts/polling.md, specs/058-tenant-ui-polish/quickstart.md
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
- [ ] T003 Create shared helper to detect “active runs exist” for tenant polling in app/Support/OpsUx/ActiveRuns.php
|
- [X] T003 Create shared helper to detect “active runs exist” for tenant polling in app/Support/OpsUx/ActiveRuns.php
|
||||||
- [ ] T004 [P] Add focused tests for the helper in tests/Feature/OpsUx/ActiveRunsTest.php
|
- [X] T004 [P] Add focused tests for the helper in tests/Feature/OpsUx/ActiveRunsTest.php
|
||||||
|
|
||||||
**Checkpoint**: Shared polling predicate exists and is covered.
|
**Checkpoint**: Shared polling predicate exists and is covered.
|
||||||
|
|
||||||
@ -34,20 +34,20 @@ ## Phase 3: User Story 1 — Drift-first tenant dashboard (Priority: P1) 🎯 MV
|
|||||||
|
|
||||||
### Tests (US1)
|
### Tests (US1)
|
||||||
|
|
||||||
- [ ] T005 [P] [US1] Add DB-only render test (no outbound HTTP, no background work) in tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
- [X] T005 [P] [US1] Add DB-only render test (no outbound HTTP, no background work) in tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
||||||
- [ ] T006 [P] [US1] Add tenant isolation test (no cross-tenant leakage) in tests/Feature/Filament/TenantDashboardTenantScopeTest.php
|
- [X] T006 [P] [US1] Add tenant isolation test (no cross-tenant leakage) in tests/Feature/Filament/TenantDashboardTenantScopeTest.php
|
||||||
|
|
||||||
### Implementation (US1)
|
### Implementation (US1)
|
||||||
|
|
||||||
- [ ] T007 [US1] Create tenant dashboard page in app/Filament/Pages/TenantDashboard.php
|
- [X] T007 [US1] Create tenant dashboard page in app/Filament/Pages/TenantDashboard.php
|
||||||
- [ ] T008 [US1] Register the tenant dashboard page in app/Providers/Filament/AdminPanelProvider.php (replace default Dashboard page entry)
|
- [X] T008 [US1] Register the tenant dashboard page in app/Providers/Filament/AdminPanelProvider.php (replace default Dashboard page entry)
|
||||||
- [ ] T009 [P] [US1] Create dashboard KPI widget(s) in app/Filament/Widgets/Dashboard/DashboardKpis.php
|
- [X] T009 [P] [US1] Create dashboard KPI widget(s) in app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||||
- [ ] T010 [P] [US1] Create “Needs Attention” widget in app/Filament/Widgets/Dashboard/NeedsAttention.php
|
- [X] T010 [P] [US1] Create “Needs Attention” widget in app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||||
- [ ] T011 [P] [US1] Create “Recent Drift Findings” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentDriftFindings.php
|
- [X] T011 [P] [US1] Create “Recent Drift Findings” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentDriftFindings.php
|
||||||
- [ ] T012 [P] [US1] Create “Recent Operations” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentOperations.php
|
- [X] T012 [P] [US1] Create “Recent Operations” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentOperations.php
|
||||||
- [ ] T013 [US1] Wire widgets into the dashboard page in app/Filament/Pages/TenantDashboard.php (header/sections) and implement conditional polling per specs/058-tenant-ui-polish/contracts/polling.md
|
- [X] T013 [US1] Wire widgets into the dashboard page in app/Filament/Pages/TenantDashboard.php (header/sections) and implement conditional polling per specs/058-tenant-ui-polish/contracts/polling.md
|
||||||
- [ ] T014 [US1] Implement drift stale rule (7 days) + CTA wiring in app/Filament/Widgets/Dashboard/NeedsAttention.php
|
- [X] T014 [US1] Implement drift stale rule (7 days) + CTA wiring in app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||||
- [ ] T015 [US1] Ensure all dashboard queries are tenant-scoped + DB-only in app/Filament/Pages/TenantDashboard.php and app/Filament/Widgets/Dashboard/*.php
|
- [X] T015 [US1] Ensure all dashboard queries are tenant-scoped + DB-only in app/Filament/Pages/TenantDashboard.php and app/Filament/Widgets/Dashboard/*.php
|
||||||
|
|
||||||
**Checkpoint**: US1 is shippable as an MVP.
|
**Checkpoint**: US1 is shippable as an MVP.
|
||||||
|
|
||||||
@ -61,22 +61,22 @@ ## Phase 4: User Story 2 — Inventory becomes a hub module (Priority: P2)
|
|||||||
|
|
||||||
### Tests (US2)
|
### Tests (US2)
|
||||||
|
|
||||||
- [ ] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php
|
- [X] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php
|
||||||
- [ ] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php
|
- [X] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php
|
||||||
|
|
||||||
### Implementation (US2)
|
### Implementation (US2)
|
||||||
|
|
||||||
- [ ] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`)
|
- [X] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`)
|
||||||
- [ ] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php
|
- [X] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php
|
||||||
- [ ] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php
|
- [X] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php
|
||||||
- [ ] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php
|
- [X] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php
|
||||||
- [ ] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php
|
- [X] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php
|
||||||
- [ ] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php
|
- [X] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php
|
||||||
- [ ] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php
|
- [X] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php
|
||||||
- [ ] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page)
|
- [X] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page)
|
||||||
- [ ] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page)
|
- [X] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page)
|
||||||
- [ ] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts)
|
- [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)
|
||||||
- [ ] T041 [US2] Inventory coverage semantics reference (A2)
|
- [X] T041 [US2] Inventory coverage semantics reference (A2)
|
||||||
- Identify and document the exact source-of-truth fields for Inventory KPI aggregation:
|
- Identify and document the exact source-of-truth fields for Inventory KPI aggregation:
|
||||||
- `inventory_items.policy_type`
|
- `inventory_items.policy_type`
|
||||||
- `config('tenantpilot.supported_policy_types')` meta fields (`restore`, `risk`)
|
- `config('tenantpilot.supported_policy_types')` meta fields (`restore`, `risk`)
|
||||||
@ -85,8 +85,8 @@ ### Implementation (US2)
|
|||||||
- DoD:
|
- DoD:
|
||||||
- One canonical place documented and referenced by inventory KPIs.
|
- One canonical place documented and referenced by inventory KPIs.
|
||||||
- No “magic” or duplicated classification logic across pages/widgets.
|
- No “magic” or duplicated classification logic across pages/widgets.
|
||||||
- [ ] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed)
|
- [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)
|
||||||
- [ ] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only)
|
- [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.
|
**Checkpoint**: Inventory hub behaves as a module with consistent sub-navigation + header.
|
||||||
|
|
||||||
@ -100,21 +100,21 @@ ## Phase 5: User Story 3 — Operations index “Orders-style” (Priority: P3)
|
|||||||
|
|
||||||
### Tests (US3)
|
### Tests (US3)
|
||||||
|
|
||||||
- [ ] T030 [P] [US3] Extend Operations DB-only test assertions in tests/Feature/Monitoring/OperationsDbOnlyTest.php (assert tabs/KPI labels appear)
|
- [X] T030 [P] [US3] Extend Operations DB-only test assertions in tests/Feature/Monitoring/OperationsDbOnlyTest.php (assert tabs/KPI labels appear)
|
||||||
- [ ] T031 [P] [US3] Extend Operations tenant isolation coverage in tests/Feature/Monitoring/OperationsTenantScopeTest.php (assert tab views don’t leak)
|
- [X] T031 [P] [US3] Extend Operations tenant isolation coverage in tests/Feature/Monitoring/OperationsTenantScopeTest.php (assert tab views don’t leak)
|
||||||
|
|
||||||
### Implementation (US3)
|
### Implementation (US3)
|
||||||
|
|
||||||
- [ ] T032 [P] [US3] Create Operations KPI header widget in app/Filament/Widgets/Operations/OperationsKpiHeader.php
|
- [X] T032 [P] [US3] Create Operations KPI header widget in app/Filament/Widgets/Operations/OperationsKpiHeader.php
|
||||||
- [ ] T033 [US3] Add KPIs to the Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
- [X] T033 [US3] Add KPIs to the Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||||
- [ ] T034 [US3] Implement status tabs (All/Active/Succeeded/Partial/Failed) on Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
- [X] T034 [US3] Implement status tabs (All/Active/Succeeded/Partial/Failed) on Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||||
- [ ] T035 [US3] Ensure tab filter logic matches specs/058-tenant-ui-polish/contracts/ui.md by adjusting queries in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
- [X] T035 [US3] Ensure tab filter logic matches specs/058-tenant-ui-polish/contracts/ui.md by adjusting queries in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||||
- [ ] T036 [US3] Implement conditional polling for Operations list (only while active runs exist) by wiring table polling in app/Filament/Resources/OperationRunResource.php and/or app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
- [X] T036 [US3] Implement conditional polling for Operations list (only while active runs exist) by wiring table polling in app/Filament/Resources/OperationRunResource.php and/or app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||||
- [ ] T037 [US3] Ensure canonical “View run” links still route to OperationRunResource view pages (no legacy routes)
|
- [X] T037 [US3] Ensure canonical “View run” links still route to OperationRunResource view pages (no legacy routes)
|
||||||
- Verify existing canonical link helper `App\Support\OperationRunLinks` is used consistently.
|
- Verify existing canonical link helper `App\Support\OperationRunLinks` is used consistently.
|
||||||
- If no suitable helper exists for a given surface, add a minimal equivalent and use it everywhere.
|
- If no suitable helper exists for a given surface, add a minimal equivalent and use it everywhere.
|
||||||
|
|
||||||
- [ ] T042 [US3] Operations terminology sweep (FR-010)
|
- [X] T042 [US3] Operations terminology sweep (FR-010)
|
||||||
- Goal: The UI uses the canonical label “Operations” consistently; no legacy naming remains.
|
- Goal: The UI uses the canonical label “Operations” consistently; no legacy naming remains.
|
||||||
- Audit + fix in:
|
- Audit + fix in:
|
||||||
- Navigation label(s)
|
- Navigation label(s)
|
||||||
@ -132,9 +132,11 @@ ### Implementation (US3)
|
|||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
- [ ] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty`
|
- [X] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty`
|
||||||
- [ ] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green
|
- [X] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green
|
||||||
- [ ] T040 [P] Smoke-check key pages render for a tenant in tests/Feature/Filament/AdminSmokeTest.php (add assertions only if gaps are found)
|
- [X] 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
49
tests/Feature/Filament/InventoryHubDbOnlyTest.php
Normal file
49
tests/Feature/Filament/InventoryHubDbOnlyTest.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
|
||||||
|
it('renders Inventory hub surfaces DB-only (no outbound HTTP, no background work)', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'display_name' => 'Item A',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => 'item-a',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventorySyncRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'selection_hash' => str_repeat('a', 64),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($tenant): void {
|
||||||
|
$this->get(InventoryItemResource::getUrl('index', tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Run Inventory Sync')
|
||||||
|
->assertSee('Item A');
|
||||||
|
|
||||||
|
$this->get(InventorySyncRunResource::getUrl('index', tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee(str_repeat('a', 12));
|
||||||
|
|
||||||
|
$this->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Policies');
|
||||||
|
});
|
||||||
|
|
||||||
|
Bus::assertNothingDispatched();
|
||||||
|
});
|
||||||
@ -1,13 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
use App\Filament\Pages\InventoryLanding;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
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();
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@ -15,14 +18,55 @@
|
|||||||
$tenant->getKey() => ['role' => 'owner'],
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'display_name' => 'Item A',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => 'item-a',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventorySyncRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'selection_hash' => str_repeat('a', 64),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
|
||||||
|
$syncRunsUrl = InventorySyncRunResource::getUrl('index', tenant: $tenant);
|
||||||
|
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
|
||||||
|
|
||||||
|
$kpiLabels = [
|
||||||
|
'Total items',
|
||||||
|
'Coverage',
|
||||||
|
'Last inventory sync',
|
||||||
|
'Active ops',
|
||||||
|
'Inventory ops',
|
||||||
|
];
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(InventoryLanding::getUrl(tenant: $tenant))
|
->get($itemsUrl)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Run Inventory Sync');
|
->assertSee('Run Inventory Sync')
|
||||||
|
->assertSee($syncRunsUrl)
|
||||||
|
->assertSee($coverageUrl)
|
||||||
|
->assertSee($kpiLabels)
|
||||||
|
->assertSee('Item A');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get($syncRunsUrl)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($itemsUrl)
|
||||||
|
->assertSee($coverageUrl)
|
||||||
|
->assertSee($kpiLabels)
|
||||||
|
->assertSee(str_repeat('a', 12));
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
->assertSee($itemsUrl)
|
||||||
|
->assertSee($syncRunsUrl)
|
||||||
|
->assertSee($kpiLabels)
|
||||||
->assertSee('Coverage')
|
->assertSee('Coverage')
|
||||||
->assertSee('Policies')
|
->assertSee('Policies')
|
||||||
->assertSee('Foundations')
|
->assertSee('Foundations')
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
|
||||||
@ -11,6 +12,12 @@
|
|||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$otherTenant = Tenant::factory()->create();
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $otherTenant->getKey(),
|
||||||
|
'external_id' => 'other-tenant-finding',
|
||||||
|
'display_name' => 'Other Tenant Policy',
|
||||||
|
]);
|
||||||
|
|
||||||
Finding::factory()->create([
|
Finding::factory()->create([
|
||||||
'tenant_id' => $otherTenant->getKey(),
|
'tenant_id' => $otherTenant->getKey(),
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
@ -29,5 +36,5 @@
|
|||||||
|
|
||||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertDontSee('other-tenant-finding');
|
->assertDontSee('Other Tenant Policy');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Pages\InventoryLanding;
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||||
use App\Jobs\RunInventorySyncJob;
|
use App\Jobs\RunInventorySyncJob;
|
||||||
use App\Livewire\BulkOperationProgress;
|
use App\Livewire\BulkOperationProgress;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
@ -24,7 +24,7 @@
|
|||||||
$sync = app(InventorySyncService::class);
|
$sync = app(InventorySyncService::class);
|
||||||
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
||||||
|
|
||||||
Livewire::test(InventoryLanding::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->callAction('run_inventory_sync', data: ['policy_types' => $allTypes])
|
->callAction('run_inventory_sync', data: ['policy_types' => $allTypes])
|
||||||
->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey());
|
->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey());
|
||||||
|
|
||||||
@ -58,7 +58,7 @@
|
|||||||
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
||||||
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
|
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
|
||||||
|
|
||||||
Livewire::test(InventoryLanding::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->mountAction('run_inventory_sync')
|
->mountAction('run_inventory_sync')
|
||||||
->set('mountedActions.0.data.policy_types', $selectedTypes)
|
->set('mountedActions.0.data.policy_types', $selectedTypes)
|
||||||
->assertActionDataSet(['policy_types' => $selectedTypes])
|
->assertActionDataSet(['policy_types' => $selectedTypes])
|
||||||
@ -85,7 +85,7 @@
|
|||||||
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
||||||
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
|
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
|
||||||
|
|
||||||
Livewire::test(InventoryLanding::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->callAction('run_inventory_sync', data: [
|
->callAction('run_inventory_sync', data: [
|
||||||
'policy_types' => $selectedTypes,
|
'policy_types' => $selectedTypes,
|
||||||
'include_dependencies' => false,
|
'include_dependencies' => false,
|
||||||
@ -110,7 +110,7 @@
|
|||||||
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
||||||
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
|
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
|
||||||
|
|
||||||
Livewire::test(InventoryLanding::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->mountAction('run_inventory_sync')
|
->mountAction('run_inventory_sync')
|
||||||
->set('mountedActions.0.data.policy_types', $selectedTypes)
|
->set('mountedActions.0.data.policy_types', $selectedTypes)
|
||||||
->assertActionDataSet(['include_foundations' => true])
|
->assertActionDataSet(['include_foundations' => true])
|
||||||
@ -135,7 +135,7 @@
|
|||||||
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
||||||
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
|
$selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes)));
|
||||||
|
|
||||||
Livewire::test(InventoryLanding::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->callAction('run_inventory_sync', data: [
|
->callAction('run_inventory_sync', data: [
|
||||||
'policy_types' => $selectedTypes,
|
'policy_types' => $selectedTypes,
|
||||||
'include_foundations' => false,
|
'include_foundations' => false,
|
||||||
@ -159,7 +159,7 @@
|
|||||||
$sync = app(InventorySyncService::class);
|
$sync = app(InventorySyncService::class);
|
||||||
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
$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])
|
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
||||||
->assertStatus(403);
|
->assertStatus(403);
|
||||||
|
|
||||||
@ -196,7 +196,7 @@
|
|||||||
'errors_count' => 0,
|
'errors_count' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(InventoryLanding::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]);
|
->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]);
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
@ -211,7 +211,7 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(InventoryLanding::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->assertActionHidden('run_inventory_sync');
|
->assertActionHidden('run_inventory_sync');
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Pages\InventoryLanding;
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||||
use App\Jobs\RunInventorySyncJob;
|
use App\Jobs\RunInventorySyncJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
@ -31,7 +31,7 @@
|
|||||||
$sync = app(InventorySyncService::class);
|
$sync = app(InventorySyncService::class);
|
||||||
$policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? [];
|
$policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? [];
|
||||||
|
|
||||||
Livewire::test(InventoryLanding::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]);
|
->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]);
|
||||||
|
|
||||||
$opRun = OperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
|
|||||||
@ -21,7 +21,16 @@
|
|||||||
|
|
||||||
assertNoOutboundHttp(function () use ($tenant) {
|
assertNoOutboundHttp(function () use ($tenant) {
|
||||||
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||||
->assertOk();
|
->assertOk()
|
||||||
|
->assertSee('Total Runs (30 days)')
|
||||||
|
->assertSee('Active Runs')
|
||||||
|
->assertSee('Failed/Partial (7 days)')
|
||||||
|
->assertSee('Avg Duration (7 days)')
|
||||||
|
->assertSee('All')
|
||||||
|
->assertSee('Active')
|
||||||
|
->assertSee('Succeeded')
|
||||||
|
->assertSee('Partial')
|
||||||
|
->assertSee('Failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
Bus::assertNothingDispatched();
|
Bus::assertNothingDispatched();
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
it('scopes Monitoring → Operations list to the active tenant', function () {
|
it('scopes Monitoring → Operations list to the active tenant', function () {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
@ -39,6 +42,85 @@
|
|||||||
->assertDontSee('TenantB');
|
->assertDontSee('TenantB');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scopes Monitoring → Operations tabs to the active tenant', function () {
|
||||||
|
$tenantA = Tenant::factory()->create();
|
||||||
|
$tenantB = Tenant::factory()->create();
|
||||||
|
|
||||||
|
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenantB->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runActiveA = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenantA->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'A-active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runSucceededA = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenantA->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
'initiator_name' => 'A-succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runPartialA = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenantA->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'partially_succeeded',
|
||||||
|
'initiator_name' => 'A-partial',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runFailedA = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenantA->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'initiator_name' => 'A-failed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runActiveB = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenantB->getKey(),
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'B-active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runFailedB = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenantB->getKey(),
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'initiator_name' => 'B-failed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenantA->makeCurrent();
|
||||||
|
Filament::setTenant($tenantA, true);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ListOperationRuns::class)
|
||||||
|
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA])
|
||||||
|
->assertCanNotSeeTableRecords([$runActiveB, $runFailedB])
|
||||||
|
->set('activeTab', 'active')
|
||||||
|
->assertCanSeeTableRecords([$runActiveA])
|
||||||
|
->assertCanNotSeeTableRecords([$runSucceededA, $runPartialA, $runFailedA, $runActiveB, $runFailedB])
|
||||||
|
->set('activeTab', 'succeeded')
|
||||||
|
->assertCanSeeTableRecords([$runSucceededA])
|
||||||
|
->assertCanNotSeeTableRecords([$runActiveA, $runPartialA, $runFailedA, $runActiveB, $runFailedB])
|
||||||
|
->set('activeTab', 'partial')
|
||||||
|
->assertCanSeeTableRecords([$runPartialA])
|
||||||
|
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runFailedA, $runActiveB, $runFailedB])
|
||||||
|
->set('activeTab', 'failed')
|
||||||
|
->assertCanSeeTableRecords([$runFailedA])
|
||||||
|
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runActiveB, $runFailedB]);
|
||||||
|
});
|
||||||
|
|
||||||
it('prevents cross-tenant access to Monitoring → Operations detail', function () {
|
it('prevents cross-tenant access to Monitoring → Operations detail', function () {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
$tenantB = Tenant::factory()->create();
|
$tenantB = Tenant::factory()->create();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Pages\InventoryLanding;
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -23,7 +23,7 @@
|
|||||||
$sync = app(InventorySyncService::class);
|
$sync = app(InventorySyncService::class);
|
||||||
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
|
$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])
|
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
||||||
->assertStatus(403);
|
->assertStatus(403);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user