058-tenant-ui-polish #70

Merged
ahmido merged 7 commits from 058-tenant-ui-polish into dev 2026-01-22 00:17:25 +00:00
14 changed files with 614 additions and 2 deletions
Showing only changes of commit 4ad3d4a7dd - Show all commits

View 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;
}
}

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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