feat(058): tenant dashboard + active-runs gating
This commit is contained in:
parent
47271c1bd0
commit
4ad3d4a7dd
34
app/Filament/Pages/TenantDashboard.php
Normal file
34
app/Filament/Pages/TenantDashboard.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
|
||||
class TenantDashboard extends Dashboard
|
||||
{
|
||||
/**
|
||||
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||
*/
|
||||
public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
DashboardKpis::class,
|
||||
NeedsAttention::class,
|
||||
RecentDriftFindings::class,
|
||||
RecentOperations::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getColumns(): int|array
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
73
app/Filament/Widgets/Dashboard/DashboardKpis.php
Normal file
73
app/Filament/Widgets/Dashboard/DashboardKpis.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class DashboardKpis extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.dashboard.dashboard-kpis';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'openDriftFindings' => 0,
|
||||
'highSeverityDriftFindings' => 0,
|
||||
'activeRuns' => 0,
|
||||
'inventoryActiveRuns' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$openDriftFindings = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->count();
|
||||
|
||||
$highSeverityDriftFindings = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->where('severity', Finding::SEVERITY_HIGH)
|
||||
->count();
|
||||
|
||||
$activeRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->count();
|
||||
|
||||
$inventoryActiveRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory.sync')
|
||||
->active()
|
||||
->count();
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'openDriftFindings' => $openDriftFindings,
|
||||
'highSeverityDriftFindings' => $highSeverityDriftFindings,
|
||||
'activeRuns' => $activeRuns,
|
||||
'inventoryActiveRuns' => $inventoryActiveRuns,
|
||||
];
|
||||
}
|
||||
}
|
||||
117
app/Filament/Widgets/Dashboard/NeedsAttention.php
Normal file
117
app/Filament/Widgets/Dashboard/NeedsAttention.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class NeedsAttention extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.dashboard.needs-attention';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$items = [];
|
||||
|
||||
$highSeverityCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->where('severity', Finding::SEVERITY_HIGH)
|
||||
->count();
|
||||
|
||||
if ($highSeverityCount > 0) {
|
||||
$items[] = [
|
||||
'title' => 'High severity drift findings',
|
||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
||||
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
||||
];
|
||||
}
|
||||
|
||||
$latestDriftSuccess = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'drift.generate')
|
||||
->where('status', 'completed')
|
||||
->where('outcome', 'succeeded')
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
->first();
|
||||
|
||||
if (! $latestDriftSuccess) {
|
||||
$items[] = [
|
||||
'title' => 'No drift scan yet',
|
||||
'body' => 'Generate drift after you have at least two successful inventory runs.',
|
||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||
];
|
||||
} else {
|
||||
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
||||
|
||||
if ($isStale) {
|
||||
$items[] = [
|
||||
'title' => 'Drift stale',
|
||||
'body' => 'Last drift scan is older than 7 days.',
|
||||
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$latestDriftFailure = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'drift.generate')
|
||||
->where('status', 'completed')
|
||||
->where('outcome', 'failed')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($latestDriftFailure instanceof OperationRun) {
|
||||
$items[] = [
|
||||
'title' => 'Drift generation failed',
|
||||
'body' => 'Investigate the latest failed run.',
|
||||
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
|
||||
];
|
||||
}
|
||||
|
||||
$activeRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->count();
|
||||
|
||||
if ($activeRuns > 0) {
|
||||
$items[] = [
|
||||
'title' => 'Operations in progress',
|
||||
'body' => "{$activeRuns} run(s) are active.",
|
||||
'url' => OperationRunLinks::index($tenant),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Filament/Widgets/Dashboard/RecentDriftFindings.php
Normal file
49
app/Filament/Widgets/Dashboard/RecentDriftFindings.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class RecentDriftFindings extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.dashboard.recent-drift-findings';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'findings' => collect(),
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
/** @var Collection<int, Finding> $findings */
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'findings' => $findings,
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Filament/Widgets/Dashboard/RecentOperations.php
Normal file
57
app/Filament/Widgets/Dashboard/RecentOperations.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class RecentOperations extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.dashboard.recent-operations';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'runs' => collect(),
|
||||
'viewRunBaseUrl' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
/** @var Collection<int, OperationRun> $runs */
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->each(function (OperationRun $run) use ($tenant): void {
|
||||
$run->setAttribute('type_label', OperationCatalog::label((string) $run->type));
|
||||
$run->setAttribute('view_url', OperationRunLinks::view($run, $tenant));
|
||||
});
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||
'runs' => $runs,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -3,12 +3,12 @@
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
@ -51,7 +51,7 @@ public function panel(Panel $panel): Panel
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
TenantDashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
|
||||
19
app/Support/OpsUx/ActiveRuns.php
Normal file
19
app/Support/OpsUx/ActiveRuns.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
|
||||
final class ActiveRuns
|
||||
{
|
||||
public static function existForTenant(Tenant $tenant): bool
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->active()
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@ -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">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>
|
||||
@ -0,0 +1,26 @@
|
||||
<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 flex-col gap-4">
|
||||
<div class="text-base font-semibold text-gray-950 dark:text-white">Needs Attention</div>
|
||||
|
||||
@if (count($items) === 0)
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Nothing urgent right now.</div>
|
||||
@else
|
||||
<div class="flex flex-col gap-3">
|
||||
@foreach ($items as $item)
|
||||
<a
|
||||
href="{{ $item['url'] }}"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-left transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,34 @@
|
||||
<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>
|
||||
@ -0,0 +1,37 @@
|
||||
<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>
|
||||
41
tests/Feature/Filament/TenantDashboardDbOnlyTest.php
Normal file
41
tests/Feature/Filament/TenantDashboardDbOnlyTest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
it('renders the tenant dashboard DB-only (no outbound HTTP, no background work)', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'System',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Bus::fake();
|
||||
|
||||
assertNoOutboundHttp(function () use ($tenant): void {
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Needs Attention')
|
||||
->assertSee('Recent Operations')
|
||||
->assertSee('Recent Drift Findings');
|
||||
});
|
||||
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
33
tests/Feature/Filament/TenantDashboardTenantScopeTest.php
Normal file
33
tests/Feature/Filament/TenantDashboardTenantScopeTest.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
|
||||
it('does not leak data across tenants on the dashboard', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => $otherTenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'subject_external_id' => 'other-tenant-finding',
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $otherTenant->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'System',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertDontSee('other-tenant-finding');
|
||||
});
|
||||
64
tests/Feature/OpsUx/ActiveRunsTest.php
Normal file
64
tests/Feature/OpsUx/ActiveRunsTest.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
|
||||
it('returns false when tenant has no active runs', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'initiator_name' => 'System',
|
||||
]);
|
||||
|
||||
expect(ActiveRuns::existForTenant($tenant))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns true when tenant has queued runs', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'System',
|
||||
]);
|
||||
|
||||
expect(ActiveRuns::existForTenant($tenant))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns true when tenant has running runs', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'System',
|
||||
]);
|
||||
|
||||
expect(ActiveRuns::existForTenant($tenant))->toBeTrue();
|
||||
});
|
||||
|
||||
it('is tenant scoped (other tenant active runs do not count)', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantB->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'initiator_name' => 'System',
|
||||
]);
|
||||
|
||||
expect(ActiveRuns::existForTenant($tenantA))->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user