Merge origin/dev into feat/057-filament-v5-upgrade
This commit is contained in:
commit
de2857c4c7
5
.github/agents/copilot-instructions.md
vendored
5
.github/agents/copilot-instructions.md
vendored
@ -11,6 +11,7 @@ ## Active Technologies
|
|||||||
- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes)
|
- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes)
|
||||||
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
||||||
- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops)
|
- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops)
|
||||||
|
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -30,9 +31,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
||||||
|
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||||
- 056-remove-legacy-bulkops: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
- 056-remove-legacy-bulkops: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
||||||
- feat/047-inventory-foundations-nodes: Added PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3
|
|
||||||
- feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
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;
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
use Filament\Pages\Dashboard;
|
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
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')
|
->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([
|
||||||
Dashboard::class,
|
TenantDashboard::class,
|
||||||
])
|
])
|
||||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||||
->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>
|
||||||
85
scripts/mcp_gitea_smoke.py
Normal file
85
scripts/mcp_gitea_smoke.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def send(proc: subprocess.Popen[bytes], payload: dict) -> None:
|
||||||
|
proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
|
||||||
|
proc.stdin.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def read_line(proc: subprocess.Popen[bytes], timeout: float = 10.0) -> str:
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if line:
|
||||||
|
return line.decode("utf-8", errors="replace").strip()
|
||||||
|
time.sleep(0.05)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["python3", "scripts/run-gitea-mcp.py"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
send(
|
||||||
|
proc,
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "smoke", "version": "0.0.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
init_resp = read_line(proc, timeout=15.0)
|
||||||
|
if not init_resp:
|
||||||
|
err = proc.stderr.read(4096).decode("utf-8", errors="replace")
|
||||||
|
sys.stderr.write("No initialize response. stderr:\n" + err + "\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("initialize:", init_resp[:400])
|
||||||
|
|
||||||
|
send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
|
||||||
|
tools_resp = read_line(proc, timeout=15.0)
|
||||||
|
if not tools_resp:
|
||||||
|
err = proc.stderr.read(4096).decode("utf-8", errors="replace")
|
||||||
|
sys.stderr.write("No tools/list response. stderr:\n" + err + "\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("tools/list:", tools_resp[:400])
|
||||||
|
|
||||||
|
send(
|
||||||
|
proc,
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": "get_my_user_info", "arguments": {}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
me_resp = read_line(proc, timeout=20.0)
|
||||||
|
if not me_resp:
|
||||||
|
err = proc.stderr.read(4096).decode("utf-8", errors="replace")
|
||||||
|
sys.stderr.write("No get_my_user_info response. stderr:\n" + err + "\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("get_my_user_info:", me_resp[:500])
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
87
scripts/run-gitea-mcp.py
Normal file
87
scripts/run-gitea-mcp.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv(path: Path) -> dict[str, str]:
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return env
|
||||||
|
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
if value and value[0] == "'" and value.endswith("'"):
|
||||||
|
value = value[1:-1]
|
||||||
|
elif value and value[0] == '"' and value.endswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
|
||||||
|
env[key] = value
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
dotenv_path = repo_root / ".env"
|
||||||
|
|
||||||
|
dotenv = load_dotenv(dotenv_path)
|
||||||
|
|
||||||
|
required = ["GITEA_HOST", "GITEA_ACCESS_TOKEN"]
|
||||||
|
missing = [k for k in required if not dotenv.get(k)]
|
||||||
|
if missing:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Missing required env vars in .env: " + ", ".join(missing) + "\n"
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
|
||||||
|
# Prefer values from .env to keep VS Code config simple.
|
||||||
|
for key in [
|
||||||
|
"GITEA_HOST",
|
||||||
|
"GITEA_ACCESS_TOKEN",
|
||||||
|
"GITEA_DEBUG",
|
||||||
|
"GITEA_READONLY",
|
||||||
|
"GITEA_INSECURE",
|
||||||
|
]:
|
||||||
|
if key in dotenv:
|
||||||
|
env[key] = dotenv[key]
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"GITEA_HOST",
|
||||||
|
"-e",
|
||||||
|
"GITEA_ACCESS_TOKEN",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Optional flags.
|
||||||
|
for optional in ["GITEA_DEBUG", "GITEA_READONLY", "GITEA_INSECURE"]:
|
||||||
|
if env.get(optional):
|
||||||
|
cmd.extend(["-e", optional])
|
||||||
|
|
||||||
|
cmd.append("docker.gitea.com/gitea-mcp-server:latest")
|
||||||
|
|
||||||
|
# Inherit stdin/stdout/stderr for MCP stdio.
|
||||||
|
completed = subprocess.run(cmd, env=env, check=False)
|
||||||
|
return int(completed.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -30,6 +30,5 @@ ## Feature Readiness
|
|||||||
- [x] No implementation details leak into specification
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
- This is a technical upgrade, but the spec describes outcomes and guardrails rather than implementation steps.
|
||||||
- This is a technical upgrade, but the spec intentionally describes outcomes and guardrails rather than implementation steps.
|
- Planning can capture concrete versions, dependency changes, and migration steps.
|
||||||
- Proceed to planning: identify concrete dependencies, sequencing, and validation steps in plan/tasks.
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ # Feature Specification: Admin UI Framework Upgrade (Panel + Suite)
|
|||||||
**Feature Branch**: `057-filament-v5-upgrade`
|
**Feature Branch**: `057-filament-v5-upgrade`
|
||||||
**Created**: 2026-01-20
|
**Created**: 2026-01-20
|
||||||
**Status**: Draft
|
**Status**: Draft
|
||||||
**Input**: Upgrade the existing admin UI framework to the next supported major release to maintain compatibility and support, and ensure no regressions for tenant isolation and Monitoring/Operations safety guardrails.
|
**Input**: Upgrade the existing admin UI stack to the next supported major release to maintain compatibility and support, and ensure no regressions for tenant isolation and Monitoring/Operations safety guardrails.
|
||||||
|
|
||||||
## Clarifications
|
## Clarifications
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ ## User Scenarios & Testing *(mandatory)*
|
|||||||
|
|
||||||
### User Story 1 - Admin UI keeps working after upgrade (Priority: P1)
|
### User Story 1 - Admin UI keeps working after upgrade (Priority: P1)
|
||||||
|
|
||||||
Administrators can sign in and use the admin panel (navigation, resources, forms, tables, actions) without runtime errors after the upgrade.
|
Administrators can sign in and use the admin panel (navigation, lists, forms, actions) without runtime errors after the upgrade.
|
||||||
|
|
||||||
**Why this priority**: This is the minimum bar for the upgrade to be safe; if the admin UI is unstable, all operational work stops.
|
**Why this priority**: This is the minimum bar for the upgrade to be safe; if the admin UI is unstable, all operational work stops.
|
||||||
|
|
||||||
@ -74,19 +74,18 @@ ## Requirements *(mandatory)*
|
|||||||
All content rendered under the Monitoring → Operations navigation section remains “DB-only” and must not perform any outbound HTTP requests during render or during background/automatic UI requests (polling/auto-refresh/hydration). Tenant isolation remains mandatory.
|
All content rendered under the Monitoring → Operations navigation section remains “DB-only” and must not perform any outbound HTTP requests during render or during background/automatic UI requests (polling/auto-refresh/hydration). Tenant isolation remains mandatory.
|
||||||
|
|
||||||
### Functional Requirements
|
### Functional Requirements
|
||||||
|
- **FR-001**: The system MUST upgrade the admin UI stack to the next supported major release and remain fully functional for all in-scope admin workflows.
|
||||||
- **FR-001**: The application MUST upgrade the admin panel framework to the next supported major release and remain fully functional for all in-scope admin workflows.
|
- **FR-002**: The system MUST continue to support the existing styling and asset pipeline without build failures.
|
||||||
- **FR-002**: The application MUST continue to support the existing styling and asset build process without build failures.
|
- **FR-003**: All existing admin pages MUST load successfully for authorized users and preserve core interactions (navigation, lists, forms, actions, notifications, and global UI elements).
|
||||||
- **FR-003**: All existing admin panel pages MUST load successfully for authorized users and preserve core interactions (navigation, lists, forms, actions, notifications, and widgets).
|
- **FR-004**: In-app navigation between admin pages MUST continue to work reliably, including any global progress indicators and event-driven UI behavior.
|
||||||
- **FR-004**: In-app navigation between admin pages MUST continue to work reliably, including global widget mounting and event-driven UI behavior.
|
- **FR-005**: Everything rendered under the Monitoring → Operations navigation section (including widgets/partials/tabs) MUST remain DB-only: no outbound HTTP requests are permitted during page render or during background/automatic requests (polling/auto-refresh/hydration).
|
||||||
- **FR-005**: Everything rendered under the Monitoring → Operations navigation section (including widgets/partials/tabs) MUST remain DB-only: no outbound HTTP requests are permitted during page render or during background/automatic UI requests (polling/auto-refresh/hydration).
|
|
||||||
- **FR-010**: Any remote work initiated from Monitoring/Operations pages MUST be triggered only by explicit user actions (e.g., buttons) and MUST enqueue a tracked operation with a persisted run record rather than performing outbound HTTP inline.
|
|
||||||
- **FR-006**: Tenant isolation MUST be preserved across requests and interactive UI behavior: all reads/writes/events/caches MUST scope to the active tenant.
|
- **FR-006**: Tenant isolation MUST be preserved across requests and interactive UI behavior: all reads/writes/events/caches MUST scope to the active tenant.
|
||||||
- **FR-007**: Compatibility risks MUST be managed by producing an explicit inventory of affected third-party dependencies and documenting upgrade/replacement decisions.
|
- **FR-007**: Compatibility risks MUST be managed by producing an explicit inventory of affected third-party dependencies and documenting upgrade/replacement decisions.
|
||||||
- **FR-011**: If a third-party package is incompatible with the upgraded stack, the system MUST preserve equivalent functionality by upgrading or replacing the package; mixed-version pinning is not allowed. Any unavoidable feature loss MUST be handled as an explicit scope/decision change.
|
|
||||||
- **FR-012**: Database migrations are allowed only if strictly required for compatibility; they MUST be reversible and non-destructive (no data loss) and MUST be mentioned in release notes.
|
|
||||||
- **FR-008**: The upgrade MUST not introduce new Microsoft Graph read/write behavior; if any Graph-touching behavior changes are required, they MUST be explicitly specified with safety gates and observability updates.
|
- **FR-008**: The upgrade MUST not introduce new Microsoft Graph read/write behavior; if any Graph-touching behavior changes are required, they MUST be explicitly specified with safety gates and observability updates.
|
||||||
- **FR-009**: The upgrade MUST include a documented rollback procedure that restores the previous working state.
|
- **FR-009**: The upgrade MUST include a documented rollback procedure that restores the previous working state.
|
||||||
|
- **FR-010**: Any remote work initiated from Monitoring/Operations pages MUST be triggered only by explicit user actions and MUST enqueue a tracked operation (with an observable run record) rather than performing outbound HTTP inline.
|
||||||
|
- **FR-011**: If a third-party dependency is incompatible with the upgraded stack, the system MUST preserve equivalent functionality by upgrading or replacing the dependency; mixed-version pinning is not allowed. Any unavoidable feature loss MUST be handled as an explicit scope/decision change.
|
||||||
|
- **FR-012**: Database migrations are allowed only if strictly required for compatibility; they MUST be reversible and non-destructive (no data loss) and MUST be mentioned in release notes.
|
||||||
|
|
||||||
### Assumptions & Dependencies
|
### Assumptions & Dependencies
|
||||||
|
|
||||||
|
|||||||
35
specs/058-tenant-ui-polish/checklists/requirements.md
Normal file
35
specs/058-tenant-ui-polish/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Tenant UI Polish (v1)
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-01-20
|
||||||
|
**Feature**: [specs/058-tenant-ui-polish/spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation run: 2026-01-20
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
22
specs/058-tenant-ui-polish/contracts/polling.md
Normal file
22
specs/058-tenant-ui-polish/contracts/polling.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Polling Contract — Calm UI Rules
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
Polling is allowed only when it materially improves UX (active operations). It must be DB-only and must stop when no longer needed.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
- Polling is enabled only while active runs exist (queued/running) for the current tenant.
|
||||||
|
- Polling is disabled when:
|
||||||
|
- No active runs exist.
|
||||||
|
|
||||||
|
## Operations index
|
||||||
|
- Polling is enabled only while active runs exist.
|
||||||
|
- Polling is disabled when:
|
||||||
|
- No active runs exist.
|
||||||
|
|
||||||
|
## Modals
|
||||||
|
- No polling inside modals.
|
||||||
|
- When a modal is open, polling should not cause churn in the background.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
- Widgets: use `$pollingInterval = null` to disable polling.
|
||||||
|
- Tables: apply `$table->poll('10s')` only when active runs exist.
|
||||||
28
specs/058-tenant-ui-polish/contracts/ui.md
Normal file
28
specs/058-tenant-ui-polish/contracts/ui.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# UI Contracts — Tenant UI Polish
|
||||||
|
|
||||||
|
This feature does not introduce HTTP APIs. These contracts describe UI routing, filters, and definitions that must remain stable.
|
||||||
|
|
||||||
|
## Routes (tenant-scoped)
|
||||||
|
- Dashboard: tenant dashboard page (new custom page; replaces default dashboard entry).
|
||||||
|
- Inventory hub: Inventory cluster root (routes to first page/resource in cluster).
|
||||||
|
- Inventory items: Inventory items resource index, under cluster prefix.
|
||||||
|
- Inventory sync runs: Inventory sync runs resource index, under cluster prefix.
|
||||||
|
- Inventory coverage: Inventory coverage page, under cluster prefix.
|
||||||
|
- Operations index: `OperationRunResource` index (`/operations`).
|
||||||
|
- Operation run detail: `OperationRunResource` view page.
|
||||||
|
|
||||||
|
## Operations Tabs (FR-009)
|
||||||
|
Tabs filter the Operations table by:
|
||||||
|
- All: no extra constraints.
|
||||||
|
- Active: `status IN ('queued','running')`
|
||||||
|
- Succeeded: `status = 'completed' AND outcome = 'succeeded'`
|
||||||
|
- Partial: `status = 'completed' AND outcome = 'partial'`
|
||||||
|
- Failed: `status = 'completed' AND outcome = 'failed'`
|
||||||
|
|
||||||
|
## KPI Definitions
|
||||||
|
- Inventory coverage % = Restorable / Total (Partial is separate, does not inflate %).
|
||||||
|
- Drift stale threshold = 7 days.
|
||||||
|
- “Recent” lists default size = 10.
|
||||||
|
- “Active operations” shows two counts:
|
||||||
|
- All active runs (queued + running)
|
||||||
|
- Inventory-active runs (type = `inventory.sync`, queued + running)
|
||||||
76
specs/058-tenant-ui-polish/data-model.md
Normal file
76
specs/058-tenant-ui-polish/data-model.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Data Model — Tenant UI Polish
|
||||||
|
|
||||||
|
This feature is read-only. It introduces no schema changes.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Tenant
|
||||||
|
- **Role**: scope boundary for all queries.
|
||||||
|
- **Source**: `Tenant::current()` (Filament tenancy).
|
||||||
|
|
||||||
|
### OperationRun
|
||||||
|
- **Role**: operations feed, KPIs, and canonical “View run” destinations.
|
||||||
|
- **Key fields used** (existing):
|
||||||
|
- `tenant_id`
|
||||||
|
- `type`
|
||||||
|
- `status` (`queued|running|completed`)
|
||||||
|
- `outcome` (`succeeded|partial|failed|...`)
|
||||||
|
- `created_at`, `started_at`, `completed_at`
|
||||||
|
- `summary_counts`, `failure_summary` (JSONB)
|
||||||
|
|
||||||
|
**Derived values**:
|
||||||
|
- **Active**: `status IN ('queued','running')`
|
||||||
|
- **Terminal**: `status = 'completed'`
|
||||||
|
- **Avg duration (7 days)**: only terminal runs with `started_at` and `completed_at`.
|
||||||
|
|
||||||
|
### InventoryItem
|
||||||
|
- **Role**: inventory totals and coverage chips.
|
||||||
|
- **Key fields used** (existing, inferred from resources):
|
||||||
|
- `tenant_id`
|
||||||
|
- coverage-related flags / fields used to categorize: Restorable, Partial, Risk, Dependencies
|
||||||
|
|
||||||
|
**Derived values**:
|
||||||
|
- Total items
|
||||||
|
- Coverage % = `restorable / total` (if total > 0)
|
||||||
|
- Chip counts: Restorable, Partial, Risk, Dependencies
|
||||||
|
|
||||||
|
### InventorySyncRun
|
||||||
|
- **Role**: “Last Inventory Sync” and “Sync Runs” list.
|
||||||
|
- **Key fields used**:
|
||||||
|
- `tenant_id`
|
||||||
|
- status + timestamps
|
||||||
|
- any “selection_hash / selection payload” metadata used for display
|
||||||
|
|
||||||
|
### Finding (Drift Finding)
|
||||||
|
- **Role**: drift KPIs and “Needs Attention”.
|
||||||
|
- **Key fields used** (existing migration):
|
||||||
|
- `tenant_id`
|
||||||
|
- `severity` (enum-like string)
|
||||||
|
- `status` (open/closed)
|
||||||
|
- timestamps
|
||||||
|
- `scope_key` for grouping
|
||||||
|
|
||||||
|
**Derived values**:
|
||||||
|
- Open findings by severity
|
||||||
|
- Staleness: last drift scan older than 7 days
|
||||||
|
|
||||||
|
## KPI Queries (read-only)
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- Drift KPIs: counts of open findings by severity + stale drift indicator.
|
||||||
|
- Operations health: counts of active runs + failed/partial recent.
|
||||||
|
- Recent lists: latest 10 findings + latest 10 operation runs.
|
||||||
|
|
||||||
|
### Inventory hub
|
||||||
|
- Total items
|
||||||
|
- Coverage % (restorable/total)
|
||||||
|
- Last inventory sync (status + timestamp)
|
||||||
|
- Active operations: (all active runs) + (inventory.sync active runs)
|
||||||
|
|
||||||
|
### Operations index
|
||||||
|
- Total runs (30d)
|
||||||
|
- Active runs (queued + running)
|
||||||
|
- Failed/partial (7d)
|
||||||
|
- Avg duration (7d, terminal runs only)
|
||||||
|
|
||||||
|
All queries must be tenant-scoped.
|
||||||
164
specs/058-tenant-ui-polish/plan.md
Normal file
164
specs/058-tenant-ui-polish/plan.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# Implementation Plan: Tenant UI Polish (Dashboard + Inventory Hub + Operations)
|
||||||
|
|
||||||
|
**Branch**: `058-tenant-ui-polish` | **Date**: 2026-01-20 | **Spec**: `specs/058-tenant-ui-polish/spec.md`
|
||||||
|
**Input**: Feature specification from `specs/058-tenant-ui-polish/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Build a drift-first, tenant-scoped dashboard with “Needs Attention” and recent lists.
|
||||||
|
- Make Inventory a hub using a Filament cluster to provide consistent left-side sub-navigation across Items / Sync Runs / Coverage.
|
||||||
|
- Upgrade Operations index to “orders-style” with KPIs + status tabs filtering the existing `OperationRunResource` table.
|
||||||
|
- Enforce DB-only renders (and DB-only polling) and a calm UI: polling only while active runs exist, and no polling churn in modals.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||||
|
for the project. The structure here is presented in advisory capacity to guide
|
||||||
|
the iteration process.
|
||||||
|
-->
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15 (Laravel 12.47.0)
|
||||||
|
**Primary Dependencies**: Filament v5.0.0, Livewire v4.0.1
|
||||||
|
**Storage**: PostgreSQL
|
||||||
|
**Testing**: Pest v4 (+ PHPUnit v12 runtime)
|
||||||
|
**Target Platform**: Web application (Filament admin panel)
|
||||||
|
**Project Type**: Web (Laravel monolith)
|
||||||
|
**Performance Goals**: Dashboard/Inventory/Operations render quickly (target <2s for typical tenants) with efficient tenant-scoped queries and no N+1.
|
||||||
|
**Constraints**: DB-only for all page renders and any polling/auto-refresh; avoid UI churn in modals.
|
||||||
|
**Scale/Scope**: Tenant-scoped surfaces; KPI math on existing `operation_runs`, `findings`, inventory tables.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: clarify what is “last observed” vs snapshots/backups
|
||||||
|
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||||
|
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||||
|
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||||
|
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||||
|
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log
|
||||||
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
|
||||||
|
Status: ✅ No constitution violations for this feature (read-only, DB-only, tenant-scoped; no Graph calls added).
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/058-tenant-ui-polish/
|
||||||
|
├── plan.md # This file (/speckit.plan command output)
|
||||||
|
├── research.md # Phase 0 output (/speckit.plan command)
|
||||||
|
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||||
|
for this feature. Delete unused options and expand the chosen structure with
|
||||||
|
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||||
|
not include Option labels.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Clusters/ # New: Inventory cluster
|
||||||
|
│ ├── Pages/ # New/updated: tenant dashboard, inventory landing, coverage
|
||||||
|
│ ├── Resources/ # Updated: attach inventory resources to cluster; operations tabs/KPIs
|
||||||
|
│ └── Widgets/ # New/updated: KPI header widgets
|
||||||
|
├── Models/ # Existing: Tenant, OperationRun, Finding, InventoryItem, InventorySyncRun
|
||||||
|
└── Providers/Filament/
|
||||||
|
└── AdminPanelProvider.php # Update: discoverClusters(), dashboard page class
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/ # Optional: partials/views for dashboard sections
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Monitoring/ # Existing: Operations DB-only + tenant scope tests
|
||||||
|
└── Filament/ # Existing + new: Inventory/Dashboard page tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Laravel monolith + Filament (v5) conventions. Implement UI changes via:
|
||||||
|
- Filament Pages (dashboard + inventory pages)
|
||||||
|
- Filament Clusters (inventory sub-navigation)
|
||||||
|
- Filament Widgets (KPI headers / recent lists)
|
||||||
|
- Filament Resource list tabs (operations index filtering)
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Phase 0 — Outline & Research (complete)
|
||||||
|
|
||||||
|
- Output: `specs/058-tenant-ui-polish/research.md`
|
||||||
|
- Key decisions captured:
|
||||||
|
- Use Filament clusters for the Inventory hub sub-navigation.
|
||||||
|
- Use Filament widgets for KPI headers.
|
||||||
|
- Enable polling only while active runs exist.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (complete)
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
- Output: `specs/058-tenant-ui-polish/data-model.md`
|
||||||
|
- No schema changes required.
|
||||||
|
|
||||||
|
### UI contracts
|
||||||
|
- Output: `specs/058-tenant-ui-polish/contracts/ui.md`
|
||||||
|
- Output: `specs/058-tenant-ui-polish/contracts/polling.md`
|
||||||
|
|
||||||
|
### Provider registration (Laravel 11+)
|
||||||
|
- Panel providers remain registered in `bootstrap/providers.php` (no changes required for this feature unless adding a new provider).
|
||||||
|
|
||||||
|
### Livewire / Filament version safety
|
||||||
|
- Livewire v4.0+ (required by Filament v5) is in use.
|
||||||
|
|
||||||
|
### Asset strategy
|
||||||
|
- Prefer existing Filament theme CSS and hook classes; avoid publishing Filament internal views.
|
||||||
|
- No heavy assets expected; if any new panel assets are added, ensure deployment runs `php artisan filament:assets`.
|
||||||
|
|
||||||
|
### Destructive actions
|
||||||
|
- None introduced in this feature.
|
||||||
|
|
||||||
|
### Constitution re-check (post-design)
|
||||||
|
- ✅ Inventory-first: dashboard uses Inventory/Findings/OperationRun as last-observed state.
|
||||||
|
- ✅ Read/write separation: this feature is read-only.
|
||||||
|
- ✅ Graph contract path: no Graph calls added.
|
||||||
|
- ✅ Tenant isolation: all queries remain tenant-scoped.
|
||||||
|
- ✅ Run observability: only consumes existing `OperationRun` records; no new long-running work is introduced.
|
||||||
|
- ✅ Data minimization: no new payload storage.
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Plan (next)
|
||||||
|
|
||||||
|
### Story 1 (P1): Drift-first tenant dashboard
|
||||||
|
- Create a custom Filament dashboard page (tenant-scoped) and wire it in `AdminPanelProvider` instead of the default `Dashboard::class`.
|
||||||
|
- Implement drift + ops KPIs and “Needs Attention” + recent lists using DB-only Eloquent queries.
|
||||||
|
- Implement conditional polling (only while active runs exist) using widget polling controls.
|
||||||
|
- Tests:
|
||||||
|
- Add DB-only coverage tests for the dashboard (no outbound HTTP; no queued jobs on render).
|
||||||
|
- Add tenant scope tests for the dashboard.
|
||||||
|
|
||||||
|
### Story 2 (P2): Inventory becomes a hub
|
||||||
|
- Add `discoverClusters()` to `AdminPanelProvider`.
|
||||||
|
- Create `InventoryCluster` and assign `$cluster` on inventory pages/resources.
|
||||||
|
- Add a shared inventory KPI header (widget) across the cluster surfaces.
|
||||||
|
- Tests:
|
||||||
|
- Extend existing inventory page tests to assert cluster pages load and remain tenant-scoped.
|
||||||
|
|
||||||
|
### Story 3 (P3): Operations index “orders-style”
|
||||||
|
- Update `OperationRunResource` list page to:
|
||||||
|
- Add KPI header widgets.
|
||||||
|
- Add tabs: All / Active / Succeeded / Partial / Failed.
|
||||||
|
- Enable table polling only while active runs exist.
|
||||||
|
- Tests:
|
||||||
|
- Extend operations tests to assert page renders with tabs and remains DB-only/tenant-scoped.
|
||||||
27
specs/058-tenant-ui-polish/quickstart.md
Normal file
27
specs/058-tenant-ui-polish/quickstart.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Quickstart — Tenant UI Polish
|
||||||
|
|
||||||
|
## Prereqs
|
||||||
|
- Run everything via Sail.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
- `vendor/bin/sail up -d`
|
||||||
|
- `vendor/bin/sail composer install`
|
||||||
|
|
||||||
|
## Run tests (targeted)
|
||||||
|
- `vendor/bin/sail artisan test tests/Feature/Monitoring/OperationsDbOnlyTest.php`
|
||||||
|
- `vendor/bin/sail artisan test tests/Feature/Monitoring/OperationsTenantScopeTest.php`
|
||||||
|
- `vendor/bin/sail artisan test tests/Feature/Filament/InventoryPagesTest.php`
|
||||||
|
|
||||||
|
When the feature is implemented, add + run:
|
||||||
|
- Dashboard DB-only + tenant scope tests (new).
|
||||||
|
|
||||||
|
## Manual QA (tenant-scoped)
|
||||||
|
- Sign in, select a tenant.
|
||||||
|
- Visit Dashboard: verify drift/ops KPIs, needs attention, and recent lists.
|
||||||
|
- Visit Inventory cluster: Items / Sync Runs / Coverage share left sub-navigation and KPI header.
|
||||||
|
- Visit Operations (`/operations`): KPI header + tabs filter table.
|
||||||
|
|
||||||
|
## Frontend assets
|
||||||
|
If UI changes don’t show:
|
||||||
|
- `vendor/bin/sail npm run dev`
|
||||||
|
- or `vendor/bin/sail npm run build`
|
||||||
66
specs/058-tenant-ui-polish/research.md
Normal file
66
specs/058-tenant-ui-polish/research.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Research — Tenant UI Polish (Dashboard + Inventory Hub + Operations)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Deliver a drift-first, tenant-scoped UI polish pass that is:
|
||||||
|
- DB-only on render and on any auto-refresh.
|
||||||
|
- Calm (polling only when needed; no modal churn).
|
||||||
|
- Consistent IA (Inventory hub sub-navigation; canonical Operations).
|
||||||
|
|
||||||
|
## Existing Code & Patterns (to reuse)
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
- Canonical list/detail already exist via `OperationRunResource` (`/operations`).
|
||||||
|
- Tenant scoping already enforced in `OperationRunResource::getEloquentQuery()`.
|
||||||
|
- Detail view already uses conditional polling with safeguards (tab hidden / modal open) via `RunDetailPolling`.
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
- Inventory entry page exists as `InventoryLanding`.
|
||||||
|
- Inventory “Items” and “Sync Runs” are currently resources (`InventoryItemResource`, `InventorySyncRunResource`).
|
||||||
|
- Inventory sync “start surface” already follows constitution rules: authorize → create/reuse `OperationRun` → enqueue job → “View run”.
|
||||||
|
|
||||||
|
### Monitoring DB-only + Tenant isolation tests
|
||||||
|
- Monitoring/Operations has DB-only tests and tenant scope tests.
|
||||||
|
- Inventory landing + coverage have basic smoke tests.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
### Decision: Use Filament clusters to implement the Inventory “hub” navigation
|
||||||
|
- **Decision**: Create an Inventory cluster and attach:
|
||||||
|
- `InventoryLanding` (page)
|
||||||
|
- Inventory items resource
|
||||||
|
- Inventory sync runs resource
|
||||||
|
- `InventoryCoverage` (page)
|
||||||
|
- **Rationale**: Filament clusters are designed for “common sub-navigation between pages”, including mixing pages and resources.
|
||||||
|
- **Notes**:
|
||||||
|
- Requires enabling cluster discovery in the panel provider.
|
||||||
|
- Sub-navigation position will be set to `Start` to achieve left-side navigation.
|
||||||
|
|
||||||
|
### Decision: Implement KPI headers as widgets (StatsOverviewWidget / TableWidget)
|
||||||
|
- **Decision**: Use Filament widgets for KPI headers on:
|
||||||
|
- Tenant dashboard (drift + ops)
|
||||||
|
- Inventory hub (inventory KPIs)
|
||||||
|
- Operations index (ops KPIs)
|
||||||
|
- **Rationale**: Widgets are first-class, composable, and can optionally poll (with `$pollingInterval`) while remaining DB-only.
|
||||||
|
|
||||||
|
### Decision: “Calm UI” auto-refresh strategy
|
||||||
|
- **Decision**:
|
||||||
|
- Dashboard + Operations index: enable polling only while active runs exist.
|
||||||
|
- Widgets/tables: polling is disabled when no active runs exist.
|
||||||
|
- No polling inside modals.
|
||||||
|
- **Rationale**: Matches FR-012 and avoids background churn.
|
||||||
|
- **Implementation approach**:
|
||||||
|
- Use Filament polling mechanisms:
|
||||||
|
- Widgets: `$pollingInterval = null | '10s'` depending on “active runs exist”.
|
||||||
|
- Tables: enable `$table->poll('10s')` only when “active runs exist”.
|
||||||
|
|
||||||
|
### Decision: No Graph / remote dependencies
|
||||||
|
- **Decision**: All queries for this feature are Eloquent/PostgreSQL queries.
|
||||||
|
- **Rationale**: Matches constitution and SC-005.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
- **Custom Blade layouts for hub navigation**: Rejected because clusters provide consistent sub-nav across resources/pages without fragile view overrides.
|
||||||
|
- **Always-on polling**: Rejected to comply with calm UI rules and avoid waste.
|
||||||
|
- **Keep `Monitoring/Operations` as canonical**: Rejected because `OperationRunResource` is already the canonical Operations surface with correct routing and detail pages.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
None — all “NEEDS CLARIFICATION” items are resolved for planning.
|
||||||
218
specs/058-tenant-ui-polish/spec.md
Normal file
218
specs/058-tenant-ui-polish/spec.md
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
# Feature Specification: Tenant UI Polish (Dashboard + Inventory Hub + Operations)
|
||||||
|
|
||||||
|
**Feature Branch**: `058-tenant-ui-polish`
|
||||||
|
**Created**: 2026-01-20
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Feature 058 — Tenant UI Polish: Dashboard + Inventory Hub + Operations \"Orders-style\" (v1)"
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-01-20
|
||||||
|
|
||||||
|
- Q: Coverage % definition for Inventory KPI header? → A: Coverage % = Restorable / Total (Partial remains a separate chip/number; main % stays conservative)
|
||||||
|
- Q: Drift stale threshold (last scan older than X days)? → A: 7 days
|
||||||
|
- Q: Inventory KPI “Active Operations” definition? → A: Show both counts: All active runs (queued + running) and Inventory-active runs (queued + running)
|
||||||
|
- Q: How many rows in “Recent” lists by default? → A: 10
|
||||||
|
|
||||||
|
### Session 2026-01-21
|
||||||
|
|
||||||
|
- Q: Operations index "Stuck" tab in v1? -> A: No "Stuck" tab in v1
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||||
|
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||||
|
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||||
|
|
||||||
|
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||||
|
Think of each story as a standalone slice of functionality that can be:
|
||||||
|
- Developed independently
|
||||||
|
- Tested independently
|
||||||
|
- Deployed independently
|
||||||
|
- Demonstrated to users independently
|
||||||
|
-->
|
||||||
|
|
||||||
|
### User Story 1 - Drift-first tenant dashboard (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant admin, I can open a tenant-scoped dashboard that immediately surfaces drift risk and operations health, without triggering any remote calls.
|
||||||
|
|
||||||
|
**Why this priority**: This is the primary entry point for day-to-day operations and should be actionable at a glance.
|
||||||
|
|
||||||
|
**Independent Test**: Visiting the dashboard shows drift + operations KPIs, a “needs attention” list with working CTAs, and recent lists, while confirming no outbound HTTP happens during render and any background UI updates.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I am signed in to a tenant, **When** I open the Dashboard, **Then** I see tenant-scoped drift KPIs, operations health KPIs, and recent lists.
|
||||||
|
2. **Given** there are urgent drift issues (e.g., high severity open findings), **When** I view the Dashboard, **Then** they appear in the “Needs Attention” section with a CTA that navigates to a filtered view.
|
||||||
|
3. **Given** drift generation has a recent failed run, **When** I view the Dashboard, **Then** I can navigate from “Needs Attention” to the related operation run details.
|
||||||
|
4. **Given** there is no drift data yet, **When** I view the Dashboard, **Then** the dashboard renders calmly with empty-state messaging and no errors.
|
||||||
|
5. **Given** the last drift scan is older than 7 days, **When** I view the Dashboard, **Then** “Needs Attention” includes a “Drift stale” item with a CTA to investigate.
|
||||||
|
6. **Given** there are more than 10 drift findings and operation runs, **When** I view the Dashboard, **Then** each “Recent” list shows the 10 most recent items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Inventory becomes a hub module (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant admin, I can use Inventory as a “hub” with consistent sub-navigation and a shared KPI header across Inventory subpages.
|
||||||
|
|
||||||
|
**Why this priority**: Inventory is a high-traffic area; a hub layout reduces cognitive load and makes it easier to find the right view quickly.
|
||||||
|
|
||||||
|
**Independent Test**: Navigating Inventory Items / Sync Runs / Coverage keeps the same shared KPI header, the left sub-navigation is consistent, and all data remains tenant-scoped and DB-only.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I am signed in to a tenant, **When** I open Inventory, **Then** I see hub navigation (Items / Sync Runs / Coverage) and a shared KPI header.
|
||||||
|
2. **Given** I switch between Inventory subpages, **When** I navigate Items → Sync Runs → Coverage, **Then** the KPI header remains visible and consistent.
|
||||||
|
3. **Given** the tenant has an inventory sync run history, **When** I open “Sync Runs”, **Then** I see only sync runs relevant to inventory synchronization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Operations index “Orders-style” (Priority: P3)
|
||||||
|
|
||||||
|
As a tenant admin, I can view Operations in an “orders-style” overview (KPIs + status tabs + table) to quickly assess activity and failures.
|
||||||
|
|
||||||
|
**Why this priority**: Operations is the canonical place to investigate work; better scanning and filtering reduces time-to-triage.
|
||||||
|
|
||||||
|
**Independent Test**: Visiting Operations index shows KPI cards and status tabs that correctly filter the table without introducing polling churn or any remote calls.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I am signed in to a tenant, **When** I open Operations, **Then** I see KPIs, status tabs, and the operations table.
|
||||||
|
2. **Given** there are active runs, **When** I click the “Active” tab, **Then** the table filters to queued + running runs only.
|
||||||
|
3. **Given** there are failed runs, **When** I click the “Failed” tab, **Then** the table filters to failed runs only.
|
||||||
|
4. **Given** I navigate away and back, **When** I return to Operations, **Then** the UI remains calm (no refresh loops) and loads quickly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Add more user stories as needed, each with an assigned priority]
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- No data yet: dashboard/inventory/operations render with empty states and helpful CTAs.
|
||||||
|
- Large tenants: KPI calculations remain fast enough to keep pages responsive.
|
||||||
|
- Mixed outcomes: partial/failed/succeeded runs are correctly categorized and discoverable via tabs/filters.
|
||||||
|
- Tenant switching: no cross-tenant leakage of KPIs, lists, or links.
|
||||||
|
- Time windows: KPI windows (e.g., last 7/30 days) handle timezones consistently.
|
||||||
|
- “Unknown” states: missing duration/end time renders gracefully (e.g., avg duration excludes non-terminal runs).
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||||
|
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||||
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
Fill them out with the right functional requirements.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001 (Tenant scope)**: System MUST ensure the Dashboard, Inventory hub, and Operations views are tenant-scoped, with no cross-tenant visibility.
|
||||||
|
- **FR-002 (DB-only surfaces)**: System MUST keep Dashboard, Inventory hub header, and Operations index DB-only during render and any background UI updates.
|
||||||
|
- **FR-003 (Placement policy)**: System MUST show KPI cards only on these entry-point pages: Dashboard, Inventory hub (shared header), and Operations index.
|
||||||
|
- **FR-004 (Inventory hub layout)**: System MUST provide an Inventory hub with left sub-navigation for Items, Sync Runs, and Coverage.
|
||||||
|
- **FR-005 (Inventory KPIs)**: Inventory hub MUST show a shared KPI header across Inventory subpages with:
|
||||||
|
- Total Items
|
||||||
|
- Coverage % (restorable items / total items; partial shown separately)
|
||||||
|
- Last Inventory Sync (status + timestamp)
|
||||||
|
- Active Operations (queued + running), showing both:
|
||||||
|
- All active runs
|
||||||
|
- Inventory-active runs
|
||||||
|
- **FR-006 (Inventory sync runs view)**: System MUST provide a “Sync Runs” view that lists only inventory synchronization runs.
|
||||||
|
- **FR-007 (Coverage chips)**: System MUST standardize coverage chips to this set only: Restorable, Partial, Risk, Dependencies.
|
||||||
|
- **FR-008 (Operations index KPIs)**: Operations index MUST show tenant-scoped KPIs:
|
||||||
|
- Total Runs (30 days)
|
||||||
|
- Active Runs (queued + running)
|
||||||
|
- Failed/Partial (7 days)
|
||||||
|
- Avg Duration (7 days, terminal runs only)
|
||||||
|
- **FR-009 (Operations tabs)**: Operations index MUST provide status tabs that filter the operations table: All, Active, Succeeded, Partial, Failed. No "Stuck" tab in v1.
|
||||||
|
- **FR-010 (Canonical terminology)**: System MUST use “Operations” as the canonical label (no legacy naming on these surfaces).
|
||||||
|
- **FR-011 (Canonical links)**: “View run” links MUST always navigate to the canonical operation run detail view.
|
||||||
|
- **FR-012 (Calm UI rules)**: System MUST avoid polling/churn in modals and avoid refresh loops; background updates should be used only where clearly necessary. Auto-refresh on Dashboard and Operations index is allowed only while active runs (queued/running) exist, and MUST stop when there are no active runs.
|
||||||
|
- **FR-013 (Drift stale rule)**: System MUST flag drift as “stale” when the last drift scan is older than 7 days and surface it in “Needs Attention” with an investigation CTA.
|
||||||
|
- **FR-014 (Recent list sizing)**: System MUST show 10 rows by default for “Recent Drift Findings” and “Recent Operations”.
|
||||||
|
|
||||||
|
### OperationRun status mapping (for tabs and KPIs)
|
||||||
|
|
||||||
|
OperationRun uses two canonical fields that drive UI filters:
|
||||||
|
|
||||||
|
- `status`: execution lifecycle (e.g., queued/running/completed)
|
||||||
|
- `outcome`: terminal result (e.g., succeeded/partially_succeeded/failed/cancelled)
|
||||||
|
|
||||||
|
Tab filters MUST map exactly as:
|
||||||
|
|
||||||
|
- **All**: no status/outcome filter
|
||||||
|
- **Active**: `status IN (queued, running)`
|
||||||
|
- **Succeeded**: `status = completed AND outcome = succeeded`
|
||||||
|
- **Partial**: `status = completed AND outcome = partially_succeeded`
|
||||||
|
- **Failed**: `status = completed AND outcome = failed`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- No “Stuck” tab in v1.
|
||||||
|
- Runs with `outcome = cancelled` appear under **All** only (unless a future “Cancelled” tab is added).
|
||||||
|
- Any legacy status/outcome values must already be normalized before reaching this UI (out of scope for this feature).
|
||||||
|
|
||||||
|
### KPI window definitions (timestamp basis)
|
||||||
|
|
||||||
|
All KPI windows are tenant-scoped and DB-only.
|
||||||
|
|
||||||
|
- **Total Runs (30 days)**: count OperationRuns by `created_at` within the last 30 days (includes all statuses/outcomes).
|
||||||
|
- **Active Runs**: current count where `status IN (queued, running)` (no time window).
|
||||||
|
- **Failed/Partial (7 days)**: count terminal runs where `status = completed AND outcome IN (failed, partially_succeeded)` and `completed_at` is within the last 7 days.
|
||||||
|
- **Avg Duration (7 days)**: average of `(completed_at - started_at)` for runs where `status = completed`, `started_at` and `completed_at` are present, and `completed_at` is within the last 7 days.
|
||||||
|
|
||||||
|
### Inventory coverage classification (Restorable/Partial/Risk/Dependencies)
|
||||||
|
|
||||||
|
Coverage chips and KPI aggregation MUST derive from the existing “policy type meta” and dependency capability signals (DB-only):
|
||||||
|
|
||||||
|
- `inventory_items.policy_type`
|
||||||
|
- `config('tenantpilot.supported_policy_types')` meta fields:
|
||||||
|
- `restore` (e.g., enabled / preview-only)
|
||||||
|
- `risk` (e.g., medium / medium-high / high)
|
||||||
|
- Dependency support computed via the existing coverage dependency resolver (based on contracts/config).
|
||||||
|
|
||||||
|
Definitions:
|
||||||
|
|
||||||
|
- **Restorable**: inventory items whose policy type meta has `restore = enabled`
|
||||||
|
- **Partial**: inventory items whose policy type meta has `restore = preview-only`
|
||||||
|
- **Risk**: inventory items whose policy type meta has `risk IN (medium-high, high)`
|
||||||
|
- **Dependencies**: inventory items whose policy type supports dependencies per the existing dependency capability resolver
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- This feature does not redefine coverage semantics; it standardizes UI rendering and KPI aggregation based on the existing policy type meta.
|
||||||
|
- If a policy type is unknown/missing meta, it MUST be treated conservatively (non-restorable) for KPI aggregation.
|
||||||
|
|
||||||
|
**Assumptions**:
|
||||||
|
- Drift findings, inventory items, and operation runs already exist as tenant-scoped data sources.
|
||||||
|
- “Coverage %” is Restorable/Total; Partial is shown separately (e.g., chips/secondary metric). If total is 0, coverage shows as not available.
|
||||||
|
- “Drift stale” default threshold is 7 days.
|
||||||
|
- “Recent” list default size is 10.
|
||||||
|
- Auto-refresh behavior (DB-only): Dashboard and Operations index auto-refresh only while active runs exist; otherwise it stops.
|
||||||
|
- Creating/generating drift is out of scope unless it can be performed as an explicit, enqueue-only user action that results in an operation run.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Tenant**: The scope boundary for all dashboards and lists in this feature.
|
||||||
|
- **Operation Run**: A tenant-scoped record of work execution, including status, timestamps, and outcomes used for Operations KPIs and recent lists.
|
||||||
|
- **Drift Finding**: A tenant-scoped record representing detected drift, including severity and state (open/closed) used for Dashboard KPIs and “Needs Attention”.
|
||||||
|
- **Inventory Item**: A tenant-scoped record representing inventory coverage and totals used in the Inventory hub.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Define measurable success criteria.
|
||||||
|
These must be technology-agnostic and measurable.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001 (Task speed)**: A tenant admin can reach “what needs attention” (drift/ops) from the dashboard within 30 seconds.
|
||||||
|
- **SC-002 (Discoverability)**: A tenant admin can find inventory sync runs within 2 clicks from Inventory.
|
||||||
|
- **SC-003 (Triage efficiency)**: A tenant admin can filter Operations to “Active” or “Failed” within 1 click and identify a run to investigate within 60 seconds.
|
||||||
|
- **SC-004 (Calm UI)**: No refresh loops are observed on Dashboard, Inventory hub pages, or Operations index during normal navigation.
|
||||||
|
- **SC-005 (Safety)**: Viewing Dashboard, Inventory hub pages, and Operations index does not trigger any outbound HTTP.
|
||||||
192
specs/058-tenant-ui-polish/tasks.md
Normal file
192
specs/058-tenant-ui-polish/tasks.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
---
|
||||||
|
description: "Task list for feature implementation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Tenant UI Polish (Dashboard + Inventory Hub + Operations)
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/058-tenant-ui-polish/`
|
||||||
|
|
||||||
|
**Tests**: Required (Pest) — this feature changes runtime UI behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**Checkpoint**: Shared polling predicate exists and is covered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Drift-first tenant dashboard (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Tenant-scoped dashboard that surfaces drift + ops KPIs and “Needs Attention”, DB-only.
|
||||||
|
|
||||||
|
**Independent Test**: Visiting the dashboard renders drift KPIs, ops KPIs, needs-attention CTAs, and recent lists (10 rows), with no outbound HTTP and no background work dispatched.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
**Checkpoint**: US1 is shippable as an MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Inventory becomes a hub module (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Inventory becomes a cluster “hub” with consistent left sub-navigation and a shared KPI header across Items / Sync Runs / Coverage.
|
||||||
|
|
||||||
|
**Independent Test**: Navigating Items → Sync Runs → Coverage keeps consistent sub-navigation and shared KPI header, tenant-scoped and DB-only.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
- 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`)
|
||||||
|
- Dependency support via existing dependency capability resolver
|
||||||
|
- Ensure KPI and chips read from this source only (DB-only).
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
**Checkpoint**: Inventory hub behaves as a module with consistent sub-navigation + header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Operations index “Orders-style” (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Operations index shows KPIs + status tabs + table, with calm conditional polling.
|
||||||
|
|
||||||
|
**Independent Test**: Visiting Operations index shows KPIs and tabs that filter runs per specs/058-tenant-ui-polish/contracts/ui.md, DB-only, calm.
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
- 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)
|
||||||
|
- Goal: The UI uses the canonical label “Operations” consistently; no legacy naming remains.
|
||||||
|
- Audit + fix in:
|
||||||
|
- Navigation label(s)
|
||||||
|
- Page titles / breadcrumbs
|
||||||
|
- Resource titles / headings
|
||||||
|
- Any links mentioning “Bulk Operation Runs” or legacy run naming
|
||||||
|
- DoD:
|
||||||
|
- No occurrences of legacy labels remain in UI surfaces for Monitoring/Operations.
|
||||||
|
- `rg -n "Bulk Operation|BulkOperationRun|Bulk Operation Runs" app resources` returns 0 matches (or matches only in tests explicitly allowed).
|
||||||
|
- If ripgrep is unavailable, use `grep -R` with the same patterns.
|
||||||
|
|
||||||
|
**Checkpoint**: Operations index is “orders-style” with calm refresh behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- US1 (P1) is standalone and can ship first.
|
||||||
|
- US2 (P2) can be implemented after foundational polling helper; it touches the panel provider and inventory pages/resources.
|
||||||
|
- US3 (P3) can be implemented after foundational polling helper; it touches the operations resource list page.
|
||||||
|
|
||||||
|
Suggested order (MVP-first): Phase 1 → Phase 2 → US1 → US2 → US3 → Polish.
|
||||||
|
|
||||||
|
### Parallel Opportunities (examples)
|
||||||
|
|
||||||
|
- US1: T009–T012 can be developed in parallel (different widget files).
|
||||||
|
- US2: T022 can be developed in parallel while T018–T021 are in review.
|
||||||
|
- US3: T032 can be developed in parallel with test updates (T030–T031).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples (per user story)
|
||||||
|
|
||||||
|
### US1
|
||||||
|
- T005 [P] [US1] tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
||||||
|
- T006 [P] [US1] tests/Feature/Filament/TenantDashboardTenantScopeTest.php
|
||||||
|
- T009 [P] [US1] app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||||
|
- T010 [P] [US1] app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||||
|
- T011 [P] [US1] app/Filament/Widgets/Dashboard/RecentDriftFindings.php
|
||||||
|
- T012 [P] [US1] app/Filament/Widgets/Dashboard/RecentOperations.php
|
||||||
|
|
||||||
|
### US2
|
||||||
|
- T016 [P] [US2] tests/Feature/Filament/InventoryHubDbOnlyTest.php
|
||||||
|
- T022 [P] [US2] app/Filament/Widgets/Inventory/InventoryKpiHeader.php
|
||||||
|
|
||||||
|
### US3
|
||||||
|
- T030 [P] [US3] tests/Feature/Monitoring/OperationsDbOnlyTest.php
|
||||||
|
- T031 [P] [US3] tests/Feature/Monitoring/OperationsTenantScopeTest.php
|
||||||
|
- T032 [P] [US3] app/Filament/Widgets/Operations/OperationsKpiHeader.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (US1 only)
|
||||||
|
|
||||||
|
1. Complete Phase 1 + Phase 2
|
||||||
|
2. Implement US1 (dashboard)
|
||||||
|
3. Run: `vendor/bin/sail artisan test tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||||
|
4. Run: `vendor/bin/sail artisan test tests/Feature/Filament/TenantDashboardTenantScopeTest.php`
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- Add US2 next (Inventory hub), then US3 (Operations index).
|
||||||
|
- After each story, run its targeted tests + the cross-cutting DB-only tests.
|
||||||
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