feat(058): tenant UI polish (dashboard, inventory hub, operations)
This commit is contained in:
parent
de899057b8
commit
b637800ef6
@ -1,14 +1,21 @@
|
||||
node_modules/
|
||||
vendor/
|
||||
.git/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.tmp
|
||||
*.swp
|
||||
public/build/
|
||||
public/hot/
|
||||
public/storage/
|
||||
storage/framework/
|
||||
storage/logs/
|
||||
storage/debugbar/
|
||||
storage/*.key
|
||||
/references/
|
||||
|
||||
@ -3,7 +3,11 @@ dist/
|
||||
build/
|
||||
public/build/
|
||||
public/hot/
|
||||
public/storage/
|
||||
coverage/
|
||||
vendor/
|
||||
storage/
|
||||
bootstrap/cache/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
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;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
@ -15,8 +18,17 @@ class InventoryCoverage extends Page
|
||||
|
||||
protected static ?string $navigationLabel = 'Coverage';
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
protected string $view = 'filament.pages.inventory-coverage';
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
InventoryKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
@ -29,12 +41,9 @@ class InventoryCoverage extends Page
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$policyTypes = config('tenantpilot.supported_policy_types', []);
|
||||
$foundationTypes = config('tenantpilot.foundation_types', []);
|
||||
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
|
||||
$this->supportedPolicyTypes = collect(is_array($policyTypes) ? $policyTypes : [])
|
||||
$this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported())
|
||||
->map(function (array $row) use ($resolver): array {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
|
||||
@ -44,7 +53,7 @@ public function mount(): void
|
||||
})
|
||||
->all();
|
||||
|
||||
$this->foundationTypes = collect(is_array($foundationTypes) ? $foundationTypes : [])
|
||||
$this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
|
||||
->map(function (array $row): array {
|
||||
return array_merge($row, [
|
||||
'dependencies' => false,
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Jobs\RunInventorySyncJob;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
@ -11,6 +13,7 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -31,10 +34,19 @@ class InventoryLanding extends Page
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Inventory';
|
||||
protected static ?string $navigationLabel = 'Overview';
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
protected string $view = 'filament.pages.inventory-landing';
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
InventoryKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
@ -66,7 +78,7 @@ protected function getHeaderActions(): array
|
||||
}),
|
||||
])
|
||||
->options(function (): array {
|
||||
return collect(config('tenantpilot.supported_policy_types', []))
|
||||
return collect(InventoryPolicyTypeMeta::supported())
|
||||
->filter(fn (array $meta): bool => filled($meta['type'] ?? null))
|
||||
->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other'))
|
||||
->mapWithKeys(function ($items, string $category): array {
|
||||
|
||||
@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||
use App\Support\Enums\RelationshipType;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
@ -24,6 +26,8 @@ class InventoryItemResource extends Resource
|
||||
{
|
||||
protected static ?string $model = InventoryItem::class;
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
@ -190,8 +194,7 @@ private static function typeMeta(?string $type): array
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect(static::allTypeMeta())
|
||||
->firstWhere('type', $type) ?? [];
|
||||
return InventoryPolicyTypeMeta::metaFor($type);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -199,12 +202,6 @@ private static function typeMeta(?string $type): array
|
||||
*/
|
||||
private static function allTypeMeta(): array
|
||||
{
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
$foundations = config('tenantpilot.foundation_types', []);
|
||||
|
||||
return array_merge(
|
||||
is_array($supported) ? $supported : [],
|
||||
is_array($foundations) ? $foundations : [],
|
||||
);
|
||||
return InventoryPolicyTypeMeta::all();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,17 @@
|
||||
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
||||
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListInventoryItems extends ListRecords
|
||||
{
|
||||
protected static string $resource = InventoryItemResource::class;
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
InventoryKpiHeader::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
@ -22,7 +23,9 @@ class InventorySyncRunResource extends Resource
|
||||
{
|
||||
protected static ?string $model = InventorySyncRun::class;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
|
||||
@ -3,9 +3,17 @@
|
||||
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListInventorySyncRuns extends ListRecords
|
||||
{
|
||||
protected static string $resource = InventorySyncRunResource::class;
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
InventoryKpiHeader::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,11 +144,6 @@ public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||
})
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
|
||||
@ -3,9 +3,62 @@
|
||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ListOperationRuns extends ListRecords
|
||||
{
|
||||
protected static string $resource = OperationRunResource::class;
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
OperationsKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<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;
|
||||
}
|
||||
}
|
||||
|
||||
131
app/Filament/Widgets/Inventory/InventoryKpiHeader.php
Normal file
131
app/Filament/Widgets/Inventory/InventoryKpiHeader.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Inventory;
|
||||
|
||||
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\InventoryPolicyTypeMeta;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class InventoryKpiHeader extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.inventory.inventory-kpi-header';
|
||||
|
||||
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<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'totalItems' => 0,
|
||||
'coveragePercent' => 0,
|
||||
'lastInventorySyncLabel' => '—',
|
||||
'activeOps' => 0,
|
||||
'inventoryOps' => 0,
|
||||
'dependenciesItems' => 0,
|
||||
'partialItems' => 0,
|
||||
'restorableItems' => 0,
|
||||
'riskItems' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
/** @var array<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();
|
||||
|
||||
$lastInventorySyncLabel = '—';
|
||||
if ($lastRun instanceof InventorySyncRun) {
|
||||
$timestamp = $lastRun->finished_at ?? $lastRun->started_at;
|
||||
|
||||
$lastInventorySyncLabel = trim(sprintf(
|
||||
'%s%s',
|
||||
(string) ($lastRun->status ?? '—'),
|
||||
$timestamp ? ' • '.$timestamp->diffForHumans() : ''
|
||||
));
|
||||
}
|
||||
|
||||
$activeOps = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$inventoryOps = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory.sync')
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
||||
|
||||
$dependenciesItems = 0;
|
||||
foreach ($countsByPolicyType as $policyType => $count) {
|
||||
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
|
||||
$dependenciesItems += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'totalItems' => $totalItems,
|
||||
'coveragePercent' => $coveragePercent,
|
||||
'lastInventorySyncLabel' => $lastInventorySyncLabel,
|
||||
'activeOps' => $activeOps,
|
||||
'inventoryOps' => $inventoryOps,
|
||||
'dependenciesItems' => $dependenciesItems,
|
||||
'partialItems' => $partialItems,
|
||||
'restorableItems' => $restorableItems,
|
||||
'riskItems' => $riskItems,
|
||||
];
|
||||
}
|
||||
}
|
||||
128
app/Filament/Widgets/Operations/OperationsKpiHeader.php
Normal file
128
app/Filament/Widgets/Operations/OperationsKpiHeader.php
Normal file
@ -0,0 +1,128 @@
|
||||
<?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\Widget;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class OperationsKpiHeader extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.operations.operations-kpi-header';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'totalRuns30Days' => 0,
|
||||
'activeRuns' => 0,
|
||||
'failedOrPartial7Days' => 0,
|
||||
'avgDuration7Days' => '—',
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$totalRuns30Days = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$activeRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->count();
|
||||
|
||||
$failedOrPartial7Days = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Failed->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
])
|
||||
->where('completed_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
|
||||
/** @var Collection<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 [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'totalRuns30Days' => $totalRuns30Days,
|
||||
'activeRuns' => $activeRuns,
|
||||
'failedOrPartial7Days' => $failedOrPartial7Days,
|
||||
'avgDuration7Days' => $avgDuration7Days,
|
||||
];
|
||||
}
|
||||
|
||||
private static function formatDurationSeconds(int $seconds): string
|
||||
{
|
||||
if ($seconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if ($seconds < 60) {
|
||||
return $seconds.'s';
|
||||
}
|
||||
|
||||
$interval = CarbonInterval::seconds($seconds)->cascade();
|
||||
|
||||
if ($seconds < 3600) {
|
||||
return sprintf('%dm %ds', $interval->minutes, $interval->seconds);
|
||||
}
|
||||
|
||||
return sprintf('%dh %dm', $interval->hours, $interval->minutes);
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,7 @@ public function panel(Panel $panel): Panel
|
||||
? view('livewire.bulk-operation-progress-wrapper')->render()
|
||||
: ''
|
||||
)
|
||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Total items</div>
|
||||
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $totalItems }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Coverage</div>
|
||||
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $coveragePercent }}%</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge size="sm" color="success">Restorable {{ $restorableItems }}</x-filament::badge>
|
||||
<x-filament::badge size="sm" color="warning">Partial {{ $partialItems }}</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Last inventory sync</div>
|
||||
<div class="text-sm font-medium text-gray-950 dark:text-white">{{ $lastInventorySyncLabel }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Active ops</div>
|
||||
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $activeOps }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Inventory ops</div>
|
||||
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $inventoryOps }}</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge size="sm" color="info">Dependencies {{ $dependenciesItems }}</x-filament::badge>
|
||||
<x-filament::badge size="sm" color="danger">Risk {{ $riskItems }}</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,28 @@
|
||||
<div
|
||||
@if ($pollingInterval)
|
||||
wire:poll.{{ $pollingInterval }}
|
||||
@endif
|
||||
class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Total Runs (30 days)</div>
|
||||
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $totalRuns30Days }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Active Runs</div>
|
||||
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $activeRuns }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Failed/Partial (7 days)</div>
|
||||
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $failedOrPartial7Days }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Duration (7 days)</div>
|
||||
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $avgDuration7Days }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -12,15 +12,15 @@ # Tasks: Tenant UI Polish (Dashboard + Inventory Hub + Operations)
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
- [ ] T001 Confirm feature inputs exist: specs/058-tenant-ui-polish/spec.md, specs/058-tenant-ui-polish/plan.md
|
||||
- [ ] T002 Confirm Phase 0/1 artifacts exist: specs/058-tenant-ui-polish/research.md, specs/058-tenant-ui-polish/data-model.md, specs/058-tenant-ui-polish/contracts/ui.md, specs/058-tenant-ui-polish/contracts/polling.md, specs/058-tenant-ui-polish/quickstart.md
|
||||
- [X] T001 Confirm feature inputs exist: specs/058-tenant-ui-polish/spec.md, specs/058-tenant-ui-polish/plan.md
|
||||
- [X] T002 Confirm Phase 0/1 artifacts exist: specs/058-tenant-ui-polish/research.md, specs/058-tenant-ui-polish/data-model.md, specs/058-tenant-ui-polish/contracts/ui.md, specs/058-tenant-ui-polish/contracts/polling.md, specs/058-tenant-ui-polish/quickstart.md
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [ ] T003 Create shared helper to detect “active runs exist” for tenant polling in app/Support/OpsUx/ActiveRuns.php
|
||||
- [ ] T004 [P] Add focused tests for the helper in tests/Feature/OpsUx/ActiveRunsTest.php
|
||||
- [X] T003 Create shared helper to detect “active runs exist” for tenant polling in app/Support/OpsUx/ActiveRuns.php
|
||||
- [X] T004 [P] Add focused tests for the helper in tests/Feature/OpsUx/ActiveRunsTest.php
|
||||
|
||||
**Checkpoint**: Shared polling predicate exists and is covered.
|
||||
|
||||
@ -34,20 +34,20 @@ ## Phase 3: User Story 1 — Drift-first tenant dashboard (Priority: P1) 🎯 MV
|
||||
|
||||
### Tests (US1)
|
||||
|
||||
- [ ] T005 [P] [US1] Add DB-only render test (no outbound HTTP, no background work) in tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
||||
- [ ] T006 [P] [US1] Add tenant isolation test (no cross-tenant leakage) in tests/Feature/Filament/TenantDashboardTenantScopeTest.php
|
||||
- [X] T005 [P] [US1] Add DB-only render test (no outbound HTTP, no background work) in tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
||||
- [X] T006 [P] [US1] Add tenant isolation test (no cross-tenant leakage) in tests/Feature/Filament/TenantDashboardTenantScopeTest.php
|
||||
|
||||
### Implementation (US1)
|
||||
|
||||
- [ ] T007 [US1] Create tenant dashboard page in app/Filament/Pages/TenantDashboard.php
|
||||
- [ ] T008 [US1] Register the tenant dashboard page in app/Providers/Filament/AdminPanelProvider.php (replace default Dashboard page entry)
|
||||
- [ ] T009 [P] [US1] Create dashboard KPI widget(s) in app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
- [ ] T010 [P] [US1] Create “Needs Attention” widget in app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
- [ ] T011 [P] [US1] Create “Recent Drift Findings” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentDriftFindings.php
|
||||
- [ ] T012 [P] [US1] Create “Recent Operations” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentOperations.php
|
||||
- [ ] T013 [US1] Wire widgets into the dashboard page in app/Filament/Pages/TenantDashboard.php (header/sections) and implement conditional polling per specs/058-tenant-ui-polish/contracts/polling.md
|
||||
- [ ] T014 [US1] Implement drift stale rule (7 days) + CTA wiring in app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
- [ ] T015 [US1] Ensure all dashboard queries are tenant-scoped + DB-only in app/Filament/Pages/TenantDashboard.php and app/Filament/Widgets/Dashboard/*.php
|
||||
- [X] T007 [US1] Create tenant dashboard page in app/Filament/Pages/TenantDashboard.php
|
||||
- [X] T008 [US1] Register the tenant dashboard page in app/Providers/Filament/AdminPanelProvider.php (replace default Dashboard page entry)
|
||||
- [X] T009 [P] [US1] Create dashboard KPI widget(s) in app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
- [X] T010 [P] [US1] Create “Needs Attention” widget in app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
- [X] T011 [P] [US1] Create “Recent Drift Findings” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentDriftFindings.php
|
||||
- [X] T012 [P] [US1] Create “Recent Operations” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentOperations.php
|
||||
- [X] T013 [US1] Wire widgets into the dashboard page in app/Filament/Pages/TenantDashboard.php (header/sections) and implement conditional polling per specs/058-tenant-ui-polish/contracts/polling.md
|
||||
- [X] T014 [US1] Implement drift stale rule (7 days) + CTA wiring in app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
- [X] T015 [US1] Ensure all dashboard queries are tenant-scoped + DB-only in app/Filament/Pages/TenantDashboard.php and app/Filament/Widgets/Dashboard/*.php
|
||||
|
||||
**Checkpoint**: US1 is shippable as an MVP.
|
||||
|
||||
@ -61,22 +61,22 @@ ## Phase 4: User Story 2 — Inventory becomes a hub module (Priority: P2)
|
||||
|
||||
### Tests (US2)
|
||||
|
||||
- [ ] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php
|
||||
- [ ] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php
|
||||
- [x] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php
|
||||
- [x] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php
|
||||
|
||||
### Implementation (US2)
|
||||
|
||||
- [ ] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`)
|
||||
- [ ] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php
|
||||
- [ ] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php
|
||||
- [ ] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php
|
||||
- [ ] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php
|
||||
- [ ] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php
|
||||
- [ ] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php
|
||||
- [ ] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page)
|
||||
- [ ] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page)
|
||||
- [ ] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts)
|
||||
- [ ] T041 [US2] Inventory coverage semantics reference (A2)
|
||||
- [x] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`)
|
||||
- [x] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php
|
||||
- [x] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php
|
||||
- [x] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php
|
||||
- [x] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php
|
||||
- [x] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php
|
||||
- [x] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php
|
||||
- [x] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page)
|
||||
- [x] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page)
|
||||
- [x] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts)
|
||||
- [x] T041 [US2] Inventory coverage semantics reference (A2)
|
||||
- Identify and document the exact source-of-truth fields for Inventory KPI aggregation:
|
||||
- `inventory_items.policy_type`
|
||||
- `config('tenantpilot.supported_policy_types')` meta fields (`restore`, `risk`)
|
||||
@ -85,8 +85,8 @@ ### Implementation (US2)
|
||||
- DoD:
|
||||
- One canonical place documented and referenced by inventory KPIs.
|
||||
- No “magic” or duplicated classification logic across pages/widgets.
|
||||
- [ ] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed)
|
||||
- [ ] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only)
|
||||
- [x] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed)
|
||||
- [x] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only)
|
||||
|
||||
**Checkpoint**: Inventory hub behaves as a module with consistent sub-navigation + header.
|
||||
|
||||
@ -100,21 +100,21 @@ ## Phase 5: User Story 3 — Operations index “Orders-style” (Priority: P3)
|
||||
|
||||
### Tests (US3)
|
||||
|
||||
- [ ] T030 [P] [US3] Extend Operations DB-only test assertions in tests/Feature/Monitoring/OperationsDbOnlyTest.php (assert tabs/KPI labels appear)
|
||||
- [ ] T031 [P] [US3] Extend Operations tenant isolation coverage in tests/Feature/Monitoring/OperationsTenantScopeTest.php (assert tab views don’t leak)
|
||||
- [X] T030 [P] [US3] Extend Operations DB-only test assertions in tests/Feature/Monitoring/OperationsDbOnlyTest.php (assert tabs/KPI labels appear)
|
||||
- [X] T031 [P] [US3] Extend Operations tenant isolation coverage in tests/Feature/Monitoring/OperationsTenantScopeTest.php (assert tab views don’t leak)
|
||||
|
||||
### Implementation (US3)
|
||||
|
||||
- [ ] T032 [P] [US3] Create Operations KPI header widget in app/Filament/Widgets/Operations/OperationsKpiHeader.php
|
||||
- [ ] T033 [US3] Add KPIs to the Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [ ] T034 [US3] Implement status tabs (All/Active/Succeeded/Partial/Failed) on Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [ ] T035 [US3] Ensure tab filter logic matches specs/058-tenant-ui-polish/contracts/ui.md by adjusting queries in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [ ] T036 [US3] Implement conditional polling for Operations list (only while active runs exist) by wiring table polling in app/Filament/Resources/OperationRunResource.php and/or app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [ ] T037 [US3] Ensure canonical “View run” links still route to OperationRunResource view pages (no legacy routes)
|
||||
- [X] T032 [P] [US3] Create Operations KPI header widget in app/Filament/Widgets/Operations/OperationsKpiHeader.php
|
||||
- [X] T033 [US3] Add KPIs to the Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [X] T034 [US3] Implement status tabs (All/Active/Succeeded/Partial/Failed) on Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [X] T035 [US3] Ensure tab filter logic matches specs/058-tenant-ui-polish/contracts/ui.md by adjusting queries in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [X] T036 [US3] Implement conditional polling for Operations list (only while active runs exist) by wiring table polling in app/Filament/Resources/OperationRunResource.php and/or app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php
|
||||
- [X] T037 [US3] Ensure canonical “View run” links still route to OperationRunResource view pages (no legacy routes)
|
||||
- Verify existing canonical link helper `App\Support\OperationRunLinks` is used consistently.
|
||||
- If no suitable helper exists for a given surface, add a minimal equivalent and use it everywhere.
|
||||
|
||||
- [ ] T042 [US3] Operations terminology sweep (FR-010)
|
||||
- [X] T042 [US3] Operations terminology sweep (FR-010)
|
||||
- Goal: The UI uses the canonical label “Operations” consistently; no legacy naming remains.
|
||||
- Audit + fix in:
|
||||
- Navigation label(s)
|
||||
@ -132,9 +132,9 @@ ### Implementation (US3)
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [ ] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty`
|
||||
- [ ] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green
|
||||
- [ ] T040 [P] Smoke-check key pages render for a tenant in tests/Feature/Filament/AdminSmokeTest.php (add assertions only if gaps are found)
|
||||
- [X] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty`
|
||||
- [X] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green
|
||||
- [X] T040 [P] Smoke-check key pages render for a tenant in tests/Feature/Filament/AdminSmokeTest.php (add assertions only if gaps are found)
|
||||
|
||||
---
|
||||
|
||||
|
||||
53
tests/Feature/Filament/InventoryHubDbOnlyTest.php
Normal file
53
tests/Feature/Filament/InventoryHubDbOnlyTest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Pages\InventoryLanding;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Models\InventoryItem;
|
||||
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(InventoryLanding::getUrl(tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Run Inventory Sync');
|
||||
|
||||
$this->get(InventoryItemResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Item A');
|
||||
|
||||
$this->get(InventorySyncRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee(str_repeat('a', 12));
|
||||
|
||||
$this->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Policies');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Pages\InventoryLanding;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
@ -20,9 +24,66 @@
|
||||
->assertOk()
|
||||
->assertSee('Run Inventory Sync');
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Item A',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'external_id' => 'item-a',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
InventorySyncRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_hash' => str_repeat('a', 64),
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
]);
|
||||
|
||||
$landingUrl = InventoryLanding::getUrl(tenant: $tenant);
|
||||
$itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
|
||||
$syncRunsUrl = InventorySyncRunResource::getUrl('index', tenant: $tenant);
|
||||
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
|
||||
|
||||
$kpiLabels = [
|
||||
'Total items',
|
||||
'Coverage',
|
||||
'Last inventory sync',
|
||||
'Active ops',
|
||||
'Inventory ops',
|
||||
];
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($landingUrl)
|
||||
->assertOk()
|
||||
->assertSee($itemsUrl)
|
||||
->assertSee($syncRunsUrl)
|
||||
->assertSee($coverageUrl)
|
||||
->assertSee($kpiLabels);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($itemsUrl)
|
||||
->assertOk()
|
||||
->assertSee($landingUrl)
|
||||
->assertSee($syncRunsUrl)
|
||||
->assertSee($coverageUrl)
|
||||
->assertSee($kpiLabels)
|
||||
->assertSee('Item A');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get($syncRunsUrl)
|
||||
->assertOk()
|
||||
->assertSee($landingUrl)
|
||||
->assertSee($itemsUrl)
|
||||
->assertSee($coverageUrl)
|
||||
->assertSee($kpiLabels)
|
||||
->assertSee(str_repeat('a', 12));
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee($landingUrl)
|
||||
->assertSee($itemsUrl)
|
||||
->assertSee($syncRunsUrl)
|
||||
->assertSee($kpiLabels)
|
||||
->assertSee('Coverage')
|
||||
->assertSee('Policies')
|
||||
->assertSee('Foundations')
|
||||
|
||||
@ -21,7 +21,16 @@
|
||||
|
||||
assertNoOutboundHttp(function () use ($tenant) {
|
||||
$this->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk();
|
||||
->assertOk()
|
||||
->assertSee('Total Runs (30 days)')
|
||||
->assertSee('Active Runs')
|
||||
->assertSee('Failed/Partial (7 days)')
|
||||
->assertSee('Avg Duration (7 days)')
|
||||
->assertSee('All')
|
||||
->assertSee('Active')
|
||||
->assertSee('Succeeded')
|
||||
->assertSee('Partial')
|
||||
->assertSee('Failed');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('scopes Monitoring → Operations list to the active tenant', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
@ -39,6 +42,85 @@
|
||||
->assertDontSee('TenantB');
|
||||
});
|
||||
|
||||
it('scopes Monitoring → Operations tabs to the active tenant', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
[$user] = createUserWithTenant($tenantA, role: 'owner');
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$runActiveA = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'A-active',
|
||||
]);
|
||||
|
||||
$runSucceededA = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'initiator_name' => 'A-succeeded',
|
||||
]);
|
||||
|
||||
$runPartialA = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'initiator_name' => 'A-partial',
|
||||
]);
|
||||
|
||||
$runFailedA = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'initiator_name' => 'A-failed',
|
||||
]);
|
||||
|
||||
$runActiveB = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantB->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'B-active',
|
||||
]);
|
||||
|
||||
$runFailedB = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantB->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'initiator_name' => 'B-failed',
|
||||
]);
|
||||
|
||||
$tenantA->makeCurrent();
|
||||
Filament::setTenant($tenantA, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListOperationRuns::class)
|
||||
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA])
|
||||
->assertCanNotSeeTableRecords([$runActiveB, $runFailedB])
|
||||
->set('activeTab', 'active')
|
||||
->assertCanSeeTableRecords([$runActiveA])
|
||||
->assertCanNotSeeTableRecords([$runSucceededA, $runPartialA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', 'succeeded')
|
||||
->assertCanSeeTableRecords([$runSucceededA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runPartialA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', 'partial')
|
||||
->assertCanSeeTableRecords([$runPartialA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', 'failed')
|
||||
->assertCanSeeTableRecords([$runFailedA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runActiveB, $runFailedB]);
|
||||
});
|
||||
|
||||
it('prevents cross-tenant access to Monitoring → Operations detail', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user