spec(057): refine Filament v5 upgrade spec #69

Merged
ahmido merged 2 commits from feat/057-filament-v5-upgrade into dev 2026-01-21 14:12:27 +00:00
28 changed files with 1630 additions and 19 deletions
Showing only changes of commit de2857c4c7 - Show all commits

View File

@ -11,6 +11,7 @@ ## Active Technologies
- 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 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)
@ -30,9 +31,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## 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
- 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 -->

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Filament\Widgets\Dashboard\RecentOperations;
use Filament\Pages\Dashboard;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
class TenantDashboard extends Dashboard
{
/**
* @return array<class-string<Widget> | WidgetConfiguration>
*/
public function getWidgets(): array
{
return [
DashboardKpis::class,
NeedsAttention::class,
RecentDriftFindings::class,
RecentOperations::class,
];
}
public function getColumns(): int|array
{
return 2;
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class DashboardKpis extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.dashboard.dashboard-kpis';
protected int|string|array $columnSpan = 'full';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'pollingInterval' => null,
'openDriftFindings' => 0,
'highSeverityDriftFindings' => 0,
'activeRuns' => 0,
'inventoryActiveRuns' => 0,
];
}
$tenantId = (int) $tenant->getKey();
$openDriftFindings = (int) Finding::query()
->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('status', Finding::STATUS_NEW)
->count();
$highSeverityDriftFindings = (int) Finding::query()
->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('status', Finding::STATUS_NEW)
->where('severity', Finding::SEVERITY_HIGH)
->count();
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->count();
$inventoryActiveRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'inventory.sync')
->active()
->count();
return [
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
'openDriftFindings' => $openDriftFindings,
'highSeverityDriftFindings' => $highSeverityDriftFindings,
'activeRuns' => $activeRuns,
'inventoryActiveRuns' => $inventoryActiveRuns,
];
}
}

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Pages\DriftLanding;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class NeedsAttention extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.dashboard.needs-attention';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'pollingInterval' => null,
'items' => [],
];
}
$tenantId = (int) $tenant->getKey();
$items = [];
$highSeverityCount = (int) Finding::query()
->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('status', Finding::STATUS_NEW)
->where('severity', Finding::SEVERITY_HIGH)
->count();
if ($highSeverityCount > 0) {
$items[] = [
'title' => 'High severity drift findings',
'body' => "{$highSeverityCount} finding(s) need review.",
'url' => FindingResource::getUrl('index', tenant: $tenant),
];
}
$latestDriftSuccess = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'drift.generate')
->where('status', 'completed')
->where('outcome', 'succeeded')
->whereNotNull('completed_at')
->latest('completed_at')
->first();
if (! $latestDriftSuccess) {
$items[] = [
'title' => 'No drift scan yet',
'body' => 'Generate drift after you have at least two successful inventory runs.',
'url' => DriftLanding::getUrl(tenant: $tenant),
];
} else {
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
if ($isStale) {
$items[] = [
'title' => 'Drift stale',
'body' => 'Last drift scan is older than 7 days.',
'url' => DriftLanding::getUrl(tenant: $tenant),
];
}
}
$latestDriftFailure = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'drift.generate')
->where('status', 'completed')
->where('outcome', 'failed')
->latest('id')
->first();
if ($latestDriftFailure instanceof OperationRun) {
$items[] = [
'title' => 'Drift generation failed',
'body' => 'Investigate the latest failed run.',
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
];
}
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->count();
if ($activeRuns > 0) {
$items[] = [
'title' => 'Operations in progress',
'body' => "{$activeRuns} run(s) are active.",
'url' => OperationRunLinks::index($tenant),
];
}
return [
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
'items' => $items,
];
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Models\Finding;
use App\Models\Tenant;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
use Illuminate\Support\Collection;
class RecentDriftFindings extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.dashboard.recent-drift-findings';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'pollingInterval' => null,
'findings' => collect(),
];
}
$tenantId = (int) $tenant->getKey();
/** @var Collection<int, Finding> $findings */
$findings = Finding::query()
->where('tenant_id', $tenantId)
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->latest('created_at')
->limit(10)
->get();
return [
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
'findings' => $findings,
];
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Dashboard;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
use Illuminate\Support\Collection;
class RecentOperations extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.dashboard.recent-operations';
protected int|string|array $columnSpan = 'full';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'pollingInterval' => null,
'runs' => collect(),
'viewRunBaseUrl' => null,
];
}
$tenantId = (int) $tenant->getKey();
/** @var Collection<int, OperationRun> $runs */
$runs = OperationRun::query()
->where('tenant_id', $tenantId)
->latest('created_at')
->limit(10)
->get()
->each(function (OperationRun $run) use ($tenant): void {
$run->setAttribute('type_label', OperationCatalog::label((string) $run->type));
$run->setAttribute('view_url', OperationRunLinks::view($run, $tenant));
});
return [
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
'runs' => $runs,
];
}
}

View File

@ -3,12 +3,12 @@
namespace App\Providers\Filament;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
@ -51,7 +51,7 @@ public function panel(Panel $panel): Panel
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
Dashboard::class,
TenantDashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Models\Tenant;
final class ActiveRuns
{
public static function existForTenant(Tenant $tenant): bool
{
return OperationRun::query()
->where('tenant_id', $tenant->getKey())
->active()
->exists();
}
}

View File

@ -0,0 +1,28 @@
<div
@if ($pollingInterval)
wire:poll.{{ $pollingInterval }}
@endif
class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"
>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Open drift findings</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $openDriftFindings }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">High severity drift</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $highSeverityDriftFindings }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Active operations</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $activeRuns }}</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Inventory active</div>
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $inventoryActiveRuns }}</div>
</div>
</div>
</div>

View File

@ -0,0 +1,26 @@
<div
@if ($pollingInterval)
wire:poll.{{ $pollingInterval }}
@endif
class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"
>
<div class="flex flex-col gap-4">
<div class="text-base font-semibold text-gray-950 dark:text-white">Needs Attention</div>
@if (count($items) === 0)
<div class="text-sm text-gray-600 dark:text-gray-300">Nothing urgent right now.</div>
@else
<div class="flex flex-col gap-3">
@foreach ($items as $item)
<a
href="{{ $item['url'] }}"
class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-left transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
</a>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,34 @@
<div
@if ($pollingInterval)
wire:poll.{{ $pollingInterval }}
@endif
class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"
>
<div class="flex items-center justify-between">
<div class="text-base font-semibold text-gray-950 dark:text-white">Recent Drift Findings</div>
</div>
<div class="mt-4">
@if ($findings->isEmpty())
<div class="text-sm text-gray-600 dark:text-gray-300">No drift findings yet.</div>
@else
<div class="flex flex-col gap-2">
@foreach ($findings as $finding)
<div class="rounded-lg border border-gray-200 p-3 dark:border-white/10">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $finding->subject_type }} · {{ $finding->subject_external_id }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $finding->created_at?->diffForHumans() }}
</div>
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Severity: {{ $finding->severity }} · Status: {{ $finding->status }}
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,37 @@
<div
@if ($pollingInterval)
wire:poll.{{ $pollingInterval }}
@endif
class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"
>
<div class="flex items-center justify-between">
<div class="text-base font-semibold text-gray-950 dark:text-white">Recent Operations</div>
</div>
<div class="mt-4">
@if ($runs->isEmpty())
<div class="text-sm text-gray-600 dark:text-gray-300">No operations yet.</div>
@else
<div class="flex flex-col gap-2">
@foreach ($runs as $run)
<a
href="{{ $run->getAttribute('view_url') }}"
class="rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5"
>
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $run->getAttribute('type_label') ?? $run->type }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $run->created_at?->diffForHumans() }}
</div>
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Status: {{ $run->status }} · Outcome: {{ $run->outcome }}
</div>
</a>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,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
View 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())

View File

@ -30,6 +30,5 @@ ## Feature Readiness
- [x] No implementation details leak into specification
## Notes
- This is a technical upgrade, but the spec intentionally describes outcomes and guardrails rather than implementation steps.
- Proceed to planning: identify concrete dependencies, sequencing, and validation steps in plan/tasks.
- This is a technical upgrade, but the spec describes outcomes and guardrails rather than implementation steps.
- Planning can capture concrete versions, dependency changes, and migration steps.

View File

@ -3,7 +3,7 @@ # Feature Specification: Admin UI Framework Upgrade (Panel + Suite)
**Feature Branch**: `057-filament-v5-upgrade`
**Created**: 2026-01-20
**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
@ -19,7 +19,7 @@ ## User Scenarios & Testing *(mandatory)*
### 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.
@ -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.
### Functional Requirements
- **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 application MUST continue to support the existing styling and asset build process without build failures.
- **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 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 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-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-002**: The system MUST continue to support the existing styling and asset pipeline 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-004**: In-app navigation between admin pages MUST continue to work reliably, including any global progress indicators 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-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-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-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
@ -98,7 +97,7 @@ ### Key Entities *(include if feature involves data)*
- **Tenant**: The active tenant context that scopes all data access and UI state.
- **Run Record**: The persisted record of long-running operations shown in Monitoring/Operations views.
- **Audit Log**: The tenant-scoped audit trail used to retain accountability for sensitive actions.
- **AuditLog**: The tenant-scoped audit trail used to retain accountability for sensitive actions.
## Success Criteria *(mandatory)*

View 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`

View 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.

View 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)

View 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.

View 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.

View 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 dont show:
- `vendor/bin/sail npm run dev`
- or `vendor/bin/sail npm run build`

View 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.

View 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.

View 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 dont 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: T009T012 can be developed in parallel (different widget files).
- US2: T022 can be developed in parallel while T018T021 are in review.
- US3: T032 can be developed in parallel with test updates (T030T031).
---
## 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.

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Finding;
use App\Models\OperationRun;
use Illuminate\Support\Facades\Bus;
it('renders the tenant dashboard DB-only (no outbound HTTP, no background work)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Finding::factory()->create([
'tenant_id' => $tenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
]);
$this->actingAs($user);
Bus::fake();
assertNoOutboundHttp(function () use ($tenant): void {
$this->get(TenantDashboard::getUrl(tenant: $tenant))
->assertOk()
->assertSee('Needs Attention')
->assertSee('Recent Operations')
->assertSee('Recent Drift Findings');
});
Bus::assertNothingDispatched();
});

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
it('does not leak data across tenants on the dashboard', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$otherTenant = Tenant::factory()->create();
Finding::factory()->create([
'tenant_id' => $otherTenant->getKey(),
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'subject_external_id' => 'other-tenant-finding',
]);
OperationRun::factory()->create([
'tenant_id' => $otherTenant->getKey(),
'type' => 'inventory.sync',
'status' => 'running',
'outcome' => 'pending',
'initiator_name' => 'System',
]);
$this->actingAs($user);
$this->get(TenantDashboard::getUrl(tenant: $tenant))
->assertOk()
->assertDontSee('other-tenant-finding');
});

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OpsUx\ActiveRuns;
it('returns false when tenant has no active runs', function (): void {
$tenant = Tenant::factory()->create();
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'inventory.sync',
'status' => 'completed',
'outcome' => 'succeeded',
'initiator_name' => 'System',
]);
expect(ActiveRuns::existForTenant($tenant))->toBeFalse();
});
it('returns true when tenant has queued runs', function (): void {
$tenant = Tenant::factory()->create();
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
]);
expect(ActiveRuns::existForTenant($tenant))->toBeTrue();
});
it('returns true when tenant has running runs', function (): void {
$tenant = Tenant::factory()->create();
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'inventory.sync',
'status' => 'running',
'outcome' => 'pending',
'initiator_name' => 'System',
]);
expect(ActiveRuns::existForTenant($tenant))->toBeTrue();
});
it('is tenant scoped (other tenant active runs do not count)', function (): void {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
OperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'type' => 'inventory.sync',
'status' => 'running',
'outcome' => 'pending',
'initiator_name' => 'System',
]);
expect(ActiveRuns::existForTenant($tenantA))->toBeFalse();
});