From 47271c1bd0ad135324ec8a12652f04446c5785c0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 21 Jan 2026 12:59:59 +0100 Subject: [PATCH 1/5] chore: add gitea mcp helper scripts --- scripts/mcp_gitea_smoke.py | 85 +++++++++++++++++++++++++++++++++++++ scripts/run-gitea-mcp.py | 87 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 scripts/mcp_gitea_smoke.py create mode 100644 scripts/run-gitea-mcp.py diff --git a/scripts/mcp_gitea_smoke.py b/scripts/mcp_gitea_smoke.py new file mode 100644 index 0000000..0906ead --- /dev/null +++ b/scripts/mcp_gitea_smoke.py @@ -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()) diff --git a/scripts/run-gitea-mcp.py b/scripts/run-gitea-mcp.py new file mode 100644 index 0000000..302ed88 --- /dev/null +++ b/scripts/run-gitea-mcp.py @@ -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()) -- 2.45.2 From 4ad3d4a7dd9735ecb7f08a18690fbf300618cd4a Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 21 Jan 2026 14:41:46 +0100 Subject: [PATCH 2/5] feat(058): tenant dashboard + active-runs gating --- app/Filament/Pages/TenantDashboard.php | 34 +++++ .../Widgets/Dashboard/DashboardKpis.php | 73 +++++++++++ .../Widgets/Dashboard/NeedsAttention.php | 117 ++++++++++++++++++ .../Widgets/Dashboard/RecentDriftFindings.php | 49 ++++++++ .../Widgets/Dashboard/RecentOperations.php | 57 +++++++++ app/Providers/Filament/AdminPanelProvider.php | 4 +- app/Support/OpsUx/ActiveRuns.php | 19 +++ .../dashboard/dashboard-kpis.blade.php | 28 +++++ .../dashboard/needs-attention.blade.php | 26 ++++ .../dashboard/recent-drift-findings.blade.php | 34 +++++ .../dashboard/recent-operations.blade.php | 37 ++++++ .../Filament/TenantDashboardDbOnlyTest.php | 41 ++++++ .../TenantDashboardTenantScopeTest.php | 33 +++++ tests/Feature/OpsUx/ActiveRunsTest.php | 64 ++++++++++ 14 files changed, 614 insertions(+), 2 deletions(-) create mode 100644 app/Filament/Pages/TenantDashboard.php create mode 100644 app/Filament/Widgets/Dashboard/DashboardKpis.php create mode 100644 app/Filament/Widgets/Dashboard/NeedsAttention.php create mode 100644 app/Filament/Widgets/Dashboard/RecentDriftFindings.php create mode 100644 app/Filament/Widgets/Dashboard/RecentOperations.php create mode 100644 app/Support/OpsUx/ActiveRuns.php create mode 100644 resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php create mode 100644 resources/views/filament/widgets/dashboard/needs-attention.blade.php create mode 100644 resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php create mode 100644 resources/views/filament/widgets/dashboard/recent-operations.blade.php create mode 100644 tests/Feature/Filament/TenantDashboardDbOnlyTest.php create mode 100644 tests/Feature/Filament/TenantDashboardTenantScopeTest.php create mode 100644 tests/Feature/OpsUx/ActiveRunsTest.php diff --git a/app/Filament/Pages/TenantDashboard.php b/app/Filament/Pages/TenantDashboard.php new file mode 100644 index 0000000..53bf2d9 --- /dev/null +++ b/app/Filament/Pages/TenantDashboard.php @@ -0,0 +1,34 @@ + | WidgetConfiguration> + */ + public function getWidgets(): array + { + return [ + DashboardKpis::class, + NeedsAttention::class, + RecentDriftFindings::class, + RecentOperations::class, + ]; + } + + public function getColumns(): int|array + { + return 2; + } +} diff --git a/app/Filament/Widgets/Dashboard/DashboardKpis.php b/app/Filament/Widgets/Dashboard/DashboardKpis.php new file mode 100644 index 0000000..6df636d --- /dev/null +++ b/app/Filament/Widgets/Dashboard/DashboardKpis.php @@ -0,0 +1,73 @@ + + */ + 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, + ]; + } +} diff --git a/app/Filament/Widgets/Dashboard/NeedsAttention.php b/app/Filament/Widgets/Dashboard/NeedsAttention.php new file mode 100644 index 0000000..4c7b76d --- /dev/null +++ b/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -0,0 +1,117 @@ + + */ + 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, + ]; + } +} diff --git a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php new file mode 100644 index 0000000..8483da0 --- /dev/null +++ b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php @@ -0,0 +1,49 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'findings' => collect(), + ]; + } + + $tenantId = (int) $tenant->getKey(); + + /** @var Collection $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, + ]; + } +} diff --git a/app/Filament/Widgets/Dashboard/RecentOperations.php b/app/Filament/Widgets/Dashboard/RecentOperations.php new file mode 100644 index 0000000..a5640b6 --- /dev/null +++ b/app/Filament/Widgets/Dashboard/RecentOperations.php @@ -0,0 +1,57 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'runs' => collect(), + 'viewRunBaseUrl' => null, + ]; + } + + $tenantId = (int) $tenant->getKey(); + + /** @var Collection $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, + ]; + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 76385ed..6684dfe 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -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([ diff --git a/app/Support/OpsUx/ActiveRuns.php b/app/Support/OpsUx/ActiveRuns.php new file mode 100644 index 0000000..3b8989d --- /dev/null +++ b/app/Support/OpsUx/ActiveRuns.php @@ -0,0 +1,19 @@ +where('tenant_id', $tenant->getKey()) + ->active() + ->exists(); + } +} diff --git a/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php b/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php new file mode 100644 index 0000000..da6c92a --- /dev/null +++ b/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php @@ -0,0 +1,28 @@ +
+
+
+
Open drift findings
+
{{ $openDriftFindings }}
+
+ +
+
High severity drift
+
{{ $highSeverityDriftFindings }}
+
+ +
+
Active operations
+
{{ $activeRuns }}
+
+ +
+
Inventory active
+
{{ $inventoryActiveRuns }}
+
+
+
diff --git a/resources/views/filament/widgets/dashboard/needs-attention.blade.php b/resources/views/filament/widgets/dashboard/needs-attention.blade.php new file mode 100644 index 0000000..e450088 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/needs-attention.blade.php @@ -0,0 +1,26 @@ +
+
+
Needs Attention
+ + @if (count($items) === 0) +
Nothing urgent right now.
+ @else +
+ @foreach ($items as $item) + +
{{ $item['title'] }}
+
{{ $item['body'] }}
+
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php b/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php new file mode 100644 index 0000000..ca3b325 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php @@ -0,0 +1,34 @@ +
+
+
Recent Drift Findings
+
+ +
+ @if ($findings->isEmpty()) +
No drift findings yet.
+ @else +
+ @foreach ($findings as $finding) +
+
+
+ {{ $finding->subject_type }} · {{ $finding->subject_external_id }} +
+
+ {{ $finding->created_at?->diffForHumans() }} +
+
+
+ Severity: {{ $finding->severity }} · Status: {{ $finding->status }} +
+
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/filament/widgets/dashboard/recent-operations.blade.php b/resources/views/filament/widgets/dashboard/recent-operations.blade.php new file mode 100644 index 0000000..8aaa690 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/recent-operations.blade.php @@ -0,0 +1,37 @@ +
+
+
Recent Operations
+
+ + +
diff --git a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php new file mode 100644 index 0000000..150e4f9 --- /dev/null +++ b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php @@ -0,0 +1,41 @@ +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(); +}); diff --git a/tests/Feature/Filament/TenantDashboardTenantScopeTest.php b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php new file mode 100644 index 0000000..9f6310c --- /dev/null +++ b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php @@ -0,0 +1,33 @@ +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'); +}); diff --git a/tests/Feature/OpsUx/ActiveRunsTest.php b/tests/Feature/OpsUx/ActiveRunsTest.php new file mode 100644 index 0000000..ba61df8 --- /dev/null +++ b/tests/Feature/OpsUx/ActiveRunsTest.php @@ -0,0 +1,64 @@ +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(); +}); -- 2.45.2 From b637800ef69c07e00d429a139b5398ccedf96fe3 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 21 Jan 2026 17:46:17 +0100 Subject: [PATCH 3/5] feat(058): tenant UI polish (dashboard, inventory hub, operations) --- .dockerignore | 7 + .prettierignore | 4 + .../Clusters/Inventory/InventoryCluster.php | 16 +++ app/Filament/Pages/InventoryCoverage.php | 19 ++- app/Filament/Pages/InventoryLanding.php | 16 ++- .../Resources/InventoryItemResource.php | 15 +- .../Pages/ListInventoryItems.php | 8 ++ .../Resources/InventorySyncRunResource.php | 5 +- .../Pages/ListInventorySyncRuns.php | 8 ++ .../Resources/OperationRunResource.php | 5 - .../Pages/ListOperationRuns.php | 53 +++++++ .../Widgets/Inventory/InventoryKpiHeader.php | 131 ++++++++++++++++++ .../Operations/OperationsKpiHeader.php | 128 +++++++++++++++++ app/Providers/Filament/AdminPanelProvider.php | 1 + .../Inventory/InventoryPolicyTypeMeta.php | 101 ++++++++++++++ .../inventory/inventory-kpi-header.blade.php | 36 +++++ .../operations-kpi-header.blade.php | 28 ++++ specs/058-tenant-ui-polish/tasks.md | 84 +++++------ .../Filament/InventoryHubDbOnlyTest.php | 53 +++++++ tests/Feature/Filament/InventoryPagesTest.php | 61 ++++++++ .../Monitoring/OperationsDbOnlyTest.php | 11 +- .../Monitoring/OperationsTenantScopeTest.php | 82 +++++++++++ 22 files changed, 807 insertions(+), 65 deletions(-) create mode 100644 app/Filament/Clusters/Inventory/InventoryCluster.php create mode 100644 app/Filament/Widgets/Inventory/InventoryKpiHeader.php create mode 100644 app/Filament/Widgets/Operations/OperationsKpiHeader.php create mode 100644 app/Support/Inventory/InventoryPolicyTypeMeta.php create mode 100644 resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php create mode 100644 resources/views/filament/widgets/operations/operations-kpi-header.blade.php create mode 100644 tests/Feature/Filament/InventoryHubDbOnlyTest.php diff --git a/.dockerignore b/.dockerignore index 9503447..95f1ba0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,21 @@ node_modules/ vendor/ .git/ +.DS_Store +Thumbs.db .env .env.* *.log npm-debug.log* yarn-debug.log* yarn-error.log* +*.tmp +*.swp public/build/ public/hot/ +public/storage/ +storage/framework/ +storage/logs/ storage/debugbar/ storage/*.key /references/ diff --git a/.prettierignore b/.prettierignore index 9cc4941..a2a9239 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,7 +3,11 @@ dist/ build/ public/build/ public/hot/ +public/storage/ coverage/ +vendor/ +storage/ +bootstrap/cache/ package-lock.json yarn.lock pnpm-lock.yaml diff --git a/app/Filament/Clusters/Inventory/InventoryCluster.php b/app/Filament/Clusters/Inventory/InventoryCluster.php new file mode 100644 index 0000000..9312759 --- /dev/null +++ b/app/Filament/Clusters/Inventory/InventoryCluster.php @@ -0,0 +1,16 @@ +> */ @@ -29,12 +41,9 @@ class InventoryCoverage extends Page public function mount(): void { - $policyTypes = config('tenantpilot.supported_policy_types', []); - $foundationTypes = config('tenantpilot.foundation_types', []); - $resolver = app(CoverageCapabilitiesResolver::class); - $this->supportedPolicyTypes = collect(is_array($policyTypes) ? $policyTypes : []) + $this->supportedPolicyTypes = collect(InventoryPolicyTypeMeta::supported()) ->map(function (array $row) use ($resolver): array { $type = (string) ($row['type'] ?? ''); @@ -44,7 +53,7 @@ public function mount(): void }) ->all(); - $this->foundationTypes = collect(is_array($foundationTypes) ? $foundationTypes : []) + $this->foundationTypes = collect(InventoryPolicyTypeMeta::foundations()) ->map(function (array $row): array { return array_merge($row, [ 'dependencies' => false, diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php index d4fee81..3e6f33d 100644 --- a/app/Filament/Pages/InventoryLanding.php +++ b/app/Filament/Pages/InventoryLanding.php @@ -2,8 +2,10 @@ namespace App\Filament\Pages; +use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventorySyncRunResource; +use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Jobs\RunInventorySyncJob; use App\Models\InventorySyncRun; use App\Models\Tenant; @@ -11,6 +13,7 @@ use App\Services\Intune\AuditLogger; use App\Services\Inventory\InventorySyncService; use App\Services\OperationRunService; +use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -31,10 +34,19 @@ class InventoryLanding extends Page protected static string|UnitEnum|null $navigationGroup = 'Inventory'; - protected static ?string $navigationLabel = 'Inventory'; + protected static ?string $navigationLabel = 'Overview'; + + protected static ?string $cluster = InventoryCluster::class; protected string $view = 'filament.pages.inventory-landing'; + protected function getHeaderWidgets(): array + { + return [ + InventoryKpiHeader::class, + ]; + } + protected function getHeaderActions(): array { return [ @@ -66,7 +78,7 @@ protected function getHeaderActions(): array }), ]) ->options(function (): array { - return collect(config('tenantpilot.supported_policy_types', [])) + return collect(InventoryPolicyTypeMeta::supported()) ->filter(fn (array $meta): bool => filled($meta['type'] ?? null)) ->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other')) ->mapWithKeys(function ($items, string $category): array { diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 4898bf6..538d7bf 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -2,12 +2,14 @@ namespace App\Filament\Resources; +use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Resources\InventoryItemResource\Pages; use App\Models\InventoryItem; use App\Models\Tenant; use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Support\Enums\RelationshipType; +use App\Support\Inventory\InventoryPolicyTypeMeta; use BackedEnum; use Filament\Actions; use Filament\Infolists\Components\TextEntry; @@ -24,6 +26,8 @@ class InventoryItemResource extends Resource { protected static ?string $model = InventoryItem::class; + protected static ?string $cluster = InventoryCluster::class; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; @@ -190,8 +194,7 @@ private static function typeMeta(?string $type): array return []; } - return collect(static::allTypeMeta()) - ->firstWhere('type', $type) ?? []; + return InventoryPolicyTypeMeta::metaFor($type); } /** @@ -199,12 +202,6 @@ private static function typeMeta(?string $type): array */ private static function allTypeMeta(): array { - $supported = config('tenantpilot.supported_policy_types', []); - $foundations = config('tenantpilot.foundation_types', []); - - return array_merge( - is_array($supported) ? $supported : [], - is_array($foundations) ? $foundations : [], - ); + return InventoryPolicyTypeMeta::all(); } } diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index fea1cdb..6792b35 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -3,9 +3,17 @@ namespace App\Filament\Resources\InventoryItemResource\Pages; use App\Filament\Resources\InventoryItemResource; +use App\Filament\Widgets\Inventory\InventoryKpiHeader; use Filament\Resources\Pages\ListRecords; class ListInventoryItems extends ListRecords { protected static string $resource = InventoryItemResource::class; + + protected function getHeaderWidgets(): array + { + return [ + InventoryKpiHeader::class, + ]; + } } diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index 731d120..1716a8e 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Resources\InventorySyncRunResource\Pages; use App\Models\InventorySyncRun; use App\Models\Tenant; @@ -22,7 +23,9 @@ class InventorySyncRunResource extends Resource { protected static ?string $model = InventorySyncRun::class; - protected static bool $shouldRegisterNavigation = false; + protected static bool $shouldRegisterNavigation = true; + + protected static ?string $cluster = InventoryCluster::class; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock'; diff --git a/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php b/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php index 024f46e..61536de 100644 --- a/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php +++ b/app/Filament/Resources/InventorySyncRunResource/Pages/ListInventorySyncRuns.php @@ -3,9 +3,17 @@ namespace App\Filament\Resources\InventorySyncRunResource\Pages; use App\Filament\Resources\InventorySyncRunResource; +use App\Filament\Widgets\Inventory\InventoryKpiHeader; use Filament\Resources\Pages\ListRecords; class ListInventorySyncRuns extends ListRecords { protected static string $resource = InventorySyncRunResource::class; + + protected function getHeaderWidgets(): array + { + return [ + InventoryKpiHeader::class, + ]; + } } diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 91e8a08..3cc29b2 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -144,11 +144,6 @@ public static function table(Table $table): Table { return $table ->defaultSort('created_at', 'desc') - ->modifyQueryUsing(function (Builder $query): Builder { - $tenantId = Tenant::current()?->getKey(); - - return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); - }) ->columns([ Tables\Columns\TextColumn::make('status') ->badge() diff --git a/app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php b/app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php index cbec2a8..5342421 100644 --- a/app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +++ b/app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php @@ -3,9 +3,62 @@ namespace App\Filament\Resources\OperationRunResource\Pages; use App\Filament\Resources\OperationRunResource; +use App\Filament\Widgets\Operations\OperationsKpiHeader; +use App\Models\Tenant; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\OpsUx\ActiveRuns; +use Filament\Facades\Filament; use Filament\Resources\Pages\ListRecords; +use Filament\Schemas\Components\Tabs\Tab; +use Illuminate\Database\Eloquent\Builder; class ListOperationRuns extends ListRecords { protected static string $resource = OperationRunResource::class; + + protected function getHeaderWidgets(): array + { + return [ + OperationsKpiHeader::class, + ]; + } + + /** + * @return array + */ + public function getTabs(): array + { + return [ + 'all' => Tab::make(), + 'active' => Tab::make() + ->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [ + OperationRunStatus::Queued->value, + OperationRunStatus::Running->value, + ])), + 'succeeded' => Tab::make() + ->modifyQueryUsing(fn (Builder $query): Builder => $query + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::Succeeded->value)), + 'partial' => Tab::make() + ->modifyQueryUsing(fn (Builder $query): Builder => $query + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::PartiallySucceeded->value)), + 'failed' => Tab::make() + ->modifyQueryUsing(fn (Builder $query): Builder => $query + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::Failed->value)), + ]; + } + + protected function getTablePollingInterval(): ?string + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return ActiveRuns::existForTenant($tenant) ? '10s' : null; + } } diff --git a/app/Filament/Widgets/Inventory/InventoryKpiHeader.php b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php new file mode 100644 index 0000000..7d8352c --- /dev/null +++ b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php @@ -0,0 +1,131 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'totalItems' => 0, + 'coveragePercent' => 0, + 'lastInventorySyncLabel' => '—', + 'activeOps' => 0, + 'inventoryOps' => 0, + 'dependenciesItems' => 0, + 'partialItems' => 0, + 'restorableItems' => 0, + 'riskItems' => 0, + ]; + } + + $tenantId = (int) $tenant->getKey(); + + /** @var array $countsByPolicyType */ + $countsByPolicyType = InventoryItem::query() + ->where('tenant_id', $tenantId) + ->selectRaw('policy_type, COUNT(*) as aggregate') + ->groupBy('policy_type') + ->pluck('aggregate', 'policy_type') + ->map(fn ($value): int => (int) $value) + ->all(); + + $totalItems = array_sum($countsByPolicyType); + + $restorableItems = 0; + $partialItems = 0; + $riskItems = 0; + + foreach ($countsByPolicyType as $policyType => $count) { + if (InventoryPolicyTypeMeta::isRestorable($policyType)) { + $restorableItems += $count; + } elseif (InventoryPolicyTypeMeta::isPartial($policyType)) { + $partialItems += $count; + } + + if (InventoryPolicyTypeMeta::isHighRisk($policyType)) { + $riskItems += $count; + } + } + + $coveragePercent = $totalItems > 0 + ? (int) round(($restorableItems / $totalItems) * 100) + : 0; + + $lastRun = InventorySyncRun::query() + ->where('tenant_id', $tenantId) + ->latest('id') + ->first(); + + $lastInventorySyncLabel = '—'; + if ($lastRun instanceof InventorySyncRun) { + $timestamp = $lastRun->finished_at ?? $lastRun->started_at; + + $lastInventorySyncLabel = trim(sprintf( + '%s%s', + (string) ($lastRun->status ?? '—'), + $timestamp ? ' • '.$timestamp->diffForHumans() : '' + )); + } + + $activeOps = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->active() + ->count(); + + $inventoryOps = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('type', 'inventory.sync') + ->active() + ->count(); + + $resolver = app(CoverageCapabilitiesResolver::class); + + $dependenciesItems = 0; + foreach ($countsByPolicyType as $policyType => $count) { + if ($policyType !== '' && $resolver->supportsDependencies($policyType)) { + $dependenciesItems += $count; + } + } + + return [ + 'totalItems' => $totalItems, + 'coveragePercent' => $coveragePercent, + 'lastInventorySyncLabel' => $lastInventorySyncLabel, + 'activeOps' => $activeOps, + 'inventoryOps' => $inventoryOps, + 'dependenciesItems' => $dependenciesItems, + 'partialItems' => $partialItems, + 'restorableItems' => $restorableItems, + 'riskItems' => $riskItems, + ]; + } +} diff --git a/app/Filament/Widgets/Operations/OperationsKpiHeader.php b/app/Filament/Widgets/Operations/OperationsKpiHeader.php new file mode 100644 index 0000000..50b02e3 --- /dev/null +++ b/app/Filament/Widgets/Operations/OperationsKpiHeader.php @@ -0,0 +1,128 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'totalRuns30Days' => 0, + 'activeRuns' => 0, + 'failedOrPartial7Days' => 0, + 'avgDuration7Days' => '—', + ]; + } + + $tenantId = (int) $tenant->getKey(); + + $totalRuns30Days = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('created_at', '>=', now()->subDays(30)) + ->count(); + + $activeRuns = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->whereIn('status', [ + OperationRunStatus::Queued->value, + OperationRunStatus::Running->value, + ]) + ->count(); + + $failedOrPartial7Days = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('status', OperationRunStatus::Completed->value) + ->whereIn('outcome', [ + OperationRunOutcome::Failed->value, + OperationRunOutcome::PartiallySucceeded->value, + ]) + ->where('completed_at', '>=', now()->subDays(7)) + ->count(); + + /** @var Collection $recentCompletedRuns */ + $recentCompletedRuns = OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('status', OperationRunStatus::Completed->value) + ->whereNotNull('started_at') + ->whereNotNull('completed_at') + ->where('completed_at', '>=', now()->subDays(7)) + ->latest('id') + ->limit(200) + ->get(['started_at', 'completed_at']); + + $durations = $recentCompletedRuns + ->map(function (OperationRun $run): ?int { + if (! $run->started_at || ! $run->completed_at) { + return null; + } + + $seconds = $run->completed_at->diffInSeconds($run->started_at); + + if (is_int($seconds)) { + return $seconds; + } + + return (int) round((float) $seconds); + }) + ->filter(fn (?int $seconds): bool => is_int($seconds) && $seconds > 0) + ->values(); + + $avgDuration7Days = '—'; + if ($durations->isNotEmpty()) { + $avgDurationSeconds = (int) round($durations->avg() ?? 0); + $avgDuration7Days = self::formatDurationSeconds($avgDurationSeconds); + } + + return [ + 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, + 'totalRuns30Days' => $totalRuns30Days, + 'activeRuns' => $activeRuns, + 'failedOrPartial7Days' => $failedOrPartial7Days, + 'avgDuration7Days' => $avgDuration7Days, + ]; + } + + private static function formatDurationSeconds(int $seconds): string + { + if ($seconds <= 0) { + return '—'; + } + + if ($seconds < 60) { + return $seconds.'s'; + } + + $interval = CarbonInterval::seconds($seconds)->cascade(); + + if ($seconds < 3600) { + return sprintf('%dm %ds', $interval->minutes, $interval->seconds); + } + + return sprintf('%dh %dm', $interval->hours, $interval->minutes); + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 6684dfe..bf4ea95 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -48,6 +48,7 @@ public function panel(Panel $panel): Panel ? view('livewire.bulk-operation-progress-wrapper')->render() : '' ) + ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ diff --git a/app/Support/Inventory/InventoryPolicyTypeMeta.php b/app/Support/Inventory/InventoryPolicyTypeMeta.php new file mode 100644 index 0000000..f72eadc --- /dev/null +++ b/app/Support/Inventory/InventoryPolicyTypeMeta.php @@ -0,0 +1,101 @@ +> + */ + public static function supported(): array + { + $supported = config('tenantpilot.supported_policy_types', []); + + return is_array($supported) ? $supported : []; + } + + /** + * @return array> + */ + public static function foundations(): array + { + $foundations = config('tenantpilot.foundation_types', []); + + return is_array($foundations) ? $foundations : []; + } + + /** + * @return array> + */ + public static function byType(): array + { + return collect(static::all()) + ->filter(fn (array $row): bool => filled($row['type'] ?? null)) + ->keyBy(fn (array $row): string => (string) $row['type']) + ->all(); + } + + /** + * @return array + */ + public static function metaFor(?string $type): array + { + if (! filled($type)) { + return []; + } + + return static::byType()[(string) $type] ?? []; + } + + public static function restoreMode(?string $type): ?string + { + $restore = static::metaFor($type)['restore'] ?? null; + + return is_string($restore) ? $restore : null; + } + + public static function riskLevel(?string $type): ?string + { + $risk = static::metaFor($type)['risk'] ?? null; + + return is_string($risk) ? $risk : null; + } + + public static function isRestorable(?string $type): bool + { + return static::restoreMode($type) === 'enabled'; + } + + public static function isPartial(?string $type): bool + { + $restore = static::restoreMode($type); + + return filled($restore) && $restore !== 'enabled'; + } + + public static function isHighRisk(?string $type): bool + { + $risk = static::riskLevel($type); + + return is_string($risk) && str_contains($risk, 'high'); + } +} diff --git a/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php b/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php new file mode 100644 index 0000000..aa11864 --- /dev/null +++ b/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php @@ -0,0 +1,36 @@ +
+
+
+
Total items
+
{{ $totalItems }}
+
+ +
+
Coverage
+
{{ $coveragePercent }}%
+
+ Restorable {{ $restorableItems }} + Partial {{ $partialItems }} +
+
+ +
+
Last inventory sync
+
{{ $lastInventorySyncLabel }}
+
+ +
+
Active ops
+
{{ $activeOps }}
+
+ +
+
Inventory ops
+
{{ $inventoryOps }}
+
+ Dependencies {{ $dependenciesItems }} + Risk {{ $riskItems }} +
+
+
+
diff --git a/resources/views/filament/widgets/operations/operations-kpi-header.blade.php b/resources/views/filament/widgets/operations/operations-kpi-header.blade.php new file mode 100644 index 0000000..3ff4b1c --- /dev/null +++ b/resources/views/filament/widgets/operations/operations-kpi-header.blade.php @@ -0,0 +1,28 @@ +
+
+
+
Total Runs (30 days)
+
{{ $totalRuns30Days }}
+
+ +
+
Active Runs
+
{{ $activeRuns }}
+
+ +
+
Failed/Partial (7 days)
+
{{ $failedOrPartial7Days }}
+
+ +
+
Avg Duration (7 days)
+
{{ $avgDuration7Days }}
+
+
+
diff --git a/specs/058-tenant-ui-polish/tasks.md b/specs/058-tenant-ui-polish/tasks.md index c4ba39c..cc3fb6d 100644 --- a/specs/058-tenant-ui-polish/tasks.md +++ b/specs/058-tenant-ui-polish/tasks.md @@ -12,15 +12,15 @@ # Tasks: Tenant UI Polish (Dashboard + Inventory Hub + Operations) ## Phase 1: Setup (Shared Infrastructure) -- [ ] T001 Confirm feature inputs exist: specs/058-tenant-ui-polish/spec.md, specs/058-tenant-ui-polish/plan.md -- [ ] T002 Confirm Phase 0/1 artifacts exist: specs/058-tenant-ui-polish/research.md, specs/058-tenant-ui-polish/data-model.md, specs/058-tenant-ui-polish/contracts/ui.md, specs/058-tenant-ui-polish/contracts/polling.md, specs/058-tenant-ui-polish/quickstart.md +- [X] T001 Confirm feature inputs exist: specs/058-tenant-ui-polish/spec.md, specs/058-tenant-ui-polish/plan.md +- [X] T002 Confirm Phase 0/1 artifacts exist: specs/058-tenant-ui-polish/research.md, specs/058-tenant-ui-polish/data-model.md, specs/058-tenant-ui-polish/contracts/ui.md, specs/058-tenant-ui-polish/contracts/polling.md, specs/058-tenant-ui-polish/quickstart.md --- ## Phase 2: Foundational (Blocking Prerequisites) -- [ ] T003 Create shared helper to detect “active runs exist” for tenant polling in app/Support/OpsUx/ActiveRuns.php -- [ ] T004 [P] Add focused tests for the helper in tests/Feature/OpsUx/ActiveRunsTest.php +- [X] T003 Create shared helper to detect “active runs exist” for tenant polling in app/Support/OpsUx/ActiveRuns.php +- [X] T004 [P] Add focused tests for the helper in tests/Feature/OpsUx/ActiveRunsTest.php **Checkpoint**: Shared polling predicate exists and is covered. @@ -34,20 +34,20 @@ ## Phase 3: User Story 1 — Drift-first tenant dashboard (Priority: P1) 🎯 MV ### Tests (US1) -- [ ] T005 [P] [US1] Add DB-only render test (no outbound HTTP, no background work) in tests/Feature/Filament/TenantDashboardDbOnlyTest.php -- [ ] T006 [P] [US1] Add tenant isolation test (no cross-tenant leakage) in tests/Feature/Filament/TenantDashboardTenantScopeTest.php +- [X] T005 [P] [US1] Add DB-only render test (no outbound HTTP, no background work) in tests/Feature/Filament/TenantDashboardDbOnlyTest.php +- [X] T006 [P] [US1] Add tenant isolation test (no cross-tenant leakage) in tests/Feature/Filament/TenantDashboardTenantScopeTest.php ### Implementation (US1) -- [ ] T007 [US1] Create tenant dashboard page in app/Filament/Pages/TenantDashboard.php -- [ ] T008 [US1] Register the tenant dashboard page in app/Providers/Filament/AdminPanelProvider.php (replace default Dashboard page entry) -- [ ] T009 [P] [US1] Create dashboard KPI widget(s) in app/Filament/Widgets/Dashboard/DashboardKpis.php -- [ ] T010 [P] [US1] Create “Needs Attention” widget in app/Filament/Widgets/Dashboard/NeedsAttention.php -- [ ] T011 [P] [US1] Create “Recent Drift Findings” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentDriftFindings.php -- [ ] T012 [P] [US1] Create “Recent Operations” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentOperations.php -- [ ] T013 [US1] Wire widgets into the dashboard page in app/Filament/Pages/TenantDashboard.php (header/sections) and implement conditional polling per specs/058-tenant-ui-polish/contracts/polling.md -- [ ] T014 [US1] Implement drift stale rule (7 days) + CTA wiring in app/Filament/Widgets/Dashboard/NeedsAttention.php -- [ ] T015 [US1] Ensure all dashboard queries are tenant-scoped + DB-only in app/Filament/Pages/TenantDashboard.php and app/Filament/Widgets/Dashboard/*.php +- [X] T007 [US1] Create tenant dashboard page in app/Filament/Pages/TenantDashboard.php +- [X] T008 [US1] Register the tenant dashboard page in app/Providers/Filament/AdminPanelProvider.php (replace default Dashboard page entry) +- [X] T009 [P] [US1] Create dashboard KPI widget(s) in app/Filament/Widgets/Dashboard/DashboardKpis.php +- [X] T010 [P] [US1] Create “Needs Attention” widget in app/Filament/Widgets/Dashboard/NeedsAttention.php +- [X] T011 [P] [US1] Create “Recent Drift Findings” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentDriftFindings.php +- [X] T012 [P] [US1] Create “Recent Operations” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentOperations.php +- [X] T013 [US1] Wire widgets into the dashboard page in app/Filament/Pages/TenantDashboard.php (header/sections) and implement conditional polling per specs/058-tenant-ui-polish/contracts/polling.md +- [X] T014 [US1] Implement drift stale rule (7 days) + CTA wiring in app/Filament/Widgets/Dashboard/NeedsAttention.php +- [X] T015 [US1] Ensure all dashboard queries are tenant-scoped + DB-only in app/Filament/Pages/TenantDashboard.php and app/Filament/Widgets/Dashboard/*.php **Checkpoint**: US1 is shippable as an MVP. @@ -61,22 +61,22 @@ ## Phase 4: User Story 2 — Inventory becomes a hub module (Priority: P2) ### Tests (US2) -- [ ] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php -- [ ] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php +- [x] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php +- [x] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php ### Implementation (US2) -- [ ] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`) -- [ ] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php -- [ ] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php -- [ ] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php -- [ ] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php -- [ ] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php -- [ ] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php -- [ ] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page) -- [ ] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page) -- [ ] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts) -- [ ] T041 [US2] Inventory coverage semantics reference (A2) +- [x] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`) +- [x] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php +- [x] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php +- [x] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php +- [x] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php +- [x] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php +- [x] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php +- [x] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page) +- [x] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page) +- [x] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts) +- [x] T041 [US2] Inventory coverage semantics reference (A2) - Identify and document the exact source-of-truth fields for Inventory KPI aggregation: - `inventory_items.policy_type` - `config('tenantpilot.supported_policy_types')` meta fields (`restore`, `risk`) @@ -85,8 +85,8 @@ ### Implementation (US2) - DoD: - One canonical place documented and referenced by inventory KPIs. - No “magic” or duplicated classification logic across pages/widgets. -- [ ] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed) -- [ ] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only) +- [x] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed) +- [x] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only) **Checkpoint**: Inventory hub behaves as a module with consistent sub-navigation + header. @@ -100,21 +100,21 @@ ## Phase 5: User Story 3 — Operations index “Orders-style” (Priority: P3) ### Tests (US3) -- [ ] T030 [P] [US3] Extend Operations DB-only test assertions in tests/Feature/Monitoring/OperationsDbOnlyTest.php (assert tabs/KPI labels appear) -- [ ] T031 [P] [US3] Extend Operations tenant isolation coverage in tests/Feature/Monitoring/OperationsTenantScopeTest.php (assert tab views don’t leak) +- [X] T030 [P] [US3] Extend Operations DB-only test assertions in tests/Feature/Monitoring/OperationsDbOnlyTest.php (assert tabs/KPI labels appear) +- [X] T031 [P] [US3] Extend Operations tenant isolation coverage in tests/Feature/Monitoring/OperationsTenantScopeTest.php (assert tab views don’t leak) ### Implementation (US3) -- [ ] T032 [P] [US3] Create Operations KPI header widget in app/Filament/Widgets/Operations/OperationsKpiHeader.php -- [ ] T033 [US3] Add KPIs to the Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php -- [ ] T034 [US3] Implement status tabs (All/Active/Succeeded/Partial/Failed) on Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php -- [ ] T035 [US3] Ensure tab filter logic matches specs/058-tenant-ui-polish/contracts/ui.md by adjusting queries in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php -- [ ] T036 [US3] Implement conditional polling for Operations list (only while active runs exist) by wiring table polling in app/Filament/Resources/OperationRunResource.php and/or app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php -- [ ] T037 [US3] Ensure canonical “View run” links still route to OperationRunResource view pages (no legacy routes) +- [X] T032 [P] [US3] Create Operations KPI header widget in app/Filament/Widgets/Operations/OperationsKpiHeader.php +- [X] T033 [US3] Add KPIs to the Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [X] T034 [US3] Implement status tabs (All/Active/Succeeded/Partial/Failed) on Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [X] T035 [US3] Ensure tab filter logic matches specs/058-tenant-ui-polish/contracts/ui.md by adjusting queries in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [X] T036 [US3] Implement conditional polling for Operations list (only while active runs exist) by wiring table polling in app/Filament/Resources/OperationRunResource.php and/or app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [X] T037 [US3] Ensure canonical “View run” links still route to OperationRunResource view pages (no legacy routes) - Verify existing canonical link helper `App\Support\OperationRunLinks` is used consistently. - If no suitable helper exists for a given surface, add a minimal equivalent and use it everywhere. -- [ ] T042 [US3] Operations terminology sweep (FR-010) +- [X] T042 [US3] Operations terminology sweep (FR-010) - Goal: The UI uses the canonical label “Operations” consistently; no legacy naming remains. - Audit + fix in: - Navigation label(s) @@ -132,9 +132,9 @@ ### Implementation (US3) ## Phase 6: Polish & Cross-Cutting Concerns -- [ ] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty` -- [ ] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green -- [ ] T040 [P] Smoke-check key pages render for a tenant in tests/Feature/Filament/AdminSmokeTest.php (add assertions only if gaps are found) +- [X] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty` +- [X] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green +- [X] T040 [P] Smoke-check key pages render for a tenant in tests/Feature/Filament/AdminSmokeTest.php (add assertions only if gaps are found) --- diff --git a/tests/Feature/Filament/InventoryHubDbOnlyTest.php b/tests/Feature/Filament/InventoryHubDbOnlyTest.php new file mode 100644 index 0000000..7e08c0c --- /dev/null +++ b/tests/Feature/Filament/InventoryHubDbOnlyTest.php @@ -0,0 +1,53 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'display_name' => 'Item A', + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'item-a', + 'platform' => 'windows', + ]); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => str_repeat('a', 64), + 'status' => InventorySyncRun::STATUS_SUCCESS, + ]); + + $this->actingAs($user); + + Bus::fake(); + + assertNoOutboundHttp(function () use ($tenant): void { + $this->get(InventoryLanding::getUrl(tenant: $tenant)) + ->assertOk() + ->assertSee('Run Inventory Sync'); + + $this->get(InventoryItemResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee('Item A'); + + $this->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee(str_repeat('a', 12)); + + $this->get(InventoryCoverage::getUrl(tenant: $tenant)) + ->assertOk() + ->assertSee('Policies'); + }); + + Bus::assertNothingDispatched(); +}); diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php index 2ec5039..687d3cf 100644 --- a/tests/Feature/Filament/InventoryPagesTest.php +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -2,6 +2,10 @@ use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryLanding; +use App\Filament\Resources\InventoryItemResource; +use App\Filament\Resources\InventorySyncRunResource; +use App\Models\InventoryItem; +use App\Models\InventorySyncRun; use App\Models\Tenant; use App\Models\User; @@ -20,9 +24,66 @@ ->assertOk() ->assertSee('Run Inventory Sync'); + InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'display_name' => 'Item A', + 'policy_type' => 'deviceConfiguration', + 'external_id' => 'item-a', + 'platform' => 'windows', + ]); + + InventorySyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_hash' => str_repeat('a', 64), + 'status' => InventorySyncRun::STATUS_SUCCESS, + ]); + + $landingUrl = InventoryLanding::getUrl(tenant: $tenant); + $itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant); + $syncRunsUrl = InventorySyncRunResource::getUrl('index', tenant: $tenant); + $coverageUrl = InventoryCoverage::getUrl(tenant: $tenant); + + $kpiLabels = [ + 'Total items', + 'Coverage', + 'Last inventory sync', + 'Active ops', + 'Inventory ops', + ]; + + $this->actingAs($user) + ->get($landingUrl) + ->assertOk() + ->assertSee($itemsUrl) + ->assertSee($syncRunsUrl) + ->assertSee($coverageUrl) + ->assertSee($kpiLabels); + + $this->actingAs($user) + ->get($itemsUrl) + ->assertOk() + ->assertSee($landingUrl) + ->assertSee($syncRunsUrl) + ->assertSee($coverageUrl) + ->assertSee($kpiLabels) + ->assertSee('Item A'); + + $this->actingAs($user) + ->get($syncRunsUrl) + ->assertOk() + ->assertSee($landingUrl) + ->assertSee($itemsUrl) + ->assertSee($coverageUrl) + ->assertSee($kpiLabels) + ->assertSee(str_repeat('a', 12)); + $this->actingAs($user) ->get(InventoryCoverage::getUrl(tenant: $tenant)) ->assertOk() + ->assertSee($landingUrl) + ->assertSee($itemsUrl) + ->assertSee($syncRunsUrl) + ->assertSee($kpiLabels) ->assertSee('Coverage') ->assertSee('Policies') ->assertSee('Foundations') diff --git a/tests/Feature/Monitoring/OperationsDbOnlyTest.php b/tests/Feature/Monitoring/OperationsDbOnlyTest.php index 258dde8..f24ee74 100644 --- a/tests/Feature/Monitoring/OperationsDbOnlyTest.php +++ b/tests/Feature/Monitoring/OperationsDbOnlyTest.php @@ -21,7 +21,16 @@ assertNoOutboundHttp(function () use ($tenant) { $this->get(OperationRunResource::getUrl('index', tenant: $tenant)) - ->assertOk(); + ->assertOk() + ->assertSee('Total Runs (30 days)') + ->assertSee('Active Runs') + ->assertSee('Failed/Partial (7 days)') + ->assertSee('Avg Duration (7 days)') + ->assertSee('All') + ->assertSee('Active') + ->assertSee('Succeeded') + ->assertSee('Partial') + ->assertSee('Failed'); }); Bus::assertNothingDispatched(); diff --git a/tests/Feature/Monitoring/OperationsTenantScopeTest.php b/tests/Feature/Monitoring/OperationsTenantScopeTest.php index 65fd6f0..b6f3c3a 100644 --- a/tests/Feature/Monitoring/OperationsTenantScopeTest.php +++ b/tests/Feature/Monitoring/OperationsTenantScopeTest.php @@ -1,8 +1,11 @@ create(); @@ -39,6 +42,85 @@ ->assertDontSee('TenantB'); }); +it('scopes Monitoring → Operations tabs to the active tenant', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + + $user->tenants()->syncWithoutDetaching([ + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $runActiveA = OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'A-active', + ]); + + $runSucceededA = OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'initiator_name' => 'A-succeeded', + ]); + + $runPartialA = OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'completed', + 'outcome' => 'partially_succeeded', + 'initiator_name' => 'A-partial', + ]); + + $runFailedA = OperationRun::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'type' => 'policy.sync', + 'status' => 'completed', + 'outcome' => 'failed', + 'initiator_name' => 'A-failed', + ]); + + $runActiveB = OperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'B-active', + ]); + + $runFailedB = OperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'type' => 'inventory.sync', + 'status' => 'completed', + 'outcome' => 'failed', + 'initiator_name' => 'B-failed', + ]); + + $tenantA->makeCurrent(); + Filament::setTenant($tenantA, true); + + Livewire::actingAs($user) + ->test(ListOperationRuns::class) + ->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA]) + ->assertCanNotSeeTableRecords([$runActiveB, $runFailedB]) + ->set('activeTab', 'active') + ->assertCanSeeTableRecords([$runActiveA]) + ->assertCanNotSeeTableRecords([$runSucceededA, $runPartialA, $runFailedA, $runActiveB, $runFailedB]) + ->set('activeTab', 'succeeded') + ->assertCanSeeTableRecords([$runSucceededA]) + ->assertCanNotSeeTableRecords([$runActiveA, $runPartialA, $runFailedA, $runActiveB, $runFailedB]) + ->set('activeTab', 'partial') + ->assertCanSeeTableRecords([$runPartialA]) + ->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runFailedA, $runActiveB, $runFailedB]) + ->set('activeTab', 'failed') + ->assertCanSeeTableRecords([$runFailedA]) + ->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runActiveB, $runFailedB]); +}); + it('prevents cross-tenant access to Monitoring → Operations detail', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); -- 2.45.2 From 8d53ad4c1e868cd28cf787387b823c0777d88029 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 22 Jan 2026 01:03:41 +0100 Subject: [PATCH 4/5] feat: Filament-native tenant dashboard widgets --- .../Widgets/Dashboard/DashboardKpis.php | 49 ++++++--- .../Widgets/Dashboard/NeedsAttention.php | 41 ++++++++ .../Widgets/Dashboard/RecentDriftFindings.php | 88 ++++++++++++----- .../Widgets/Dashboard/RecentOperations.php | 99 +++++++++++++------ .../dashboard/dashboard-kpis.blade.php | 28 ------ .../dashboard/needs-attention.blade.php | 61 +++++++++--- .../dashboard/recent-drift-findings.blade.php | 34 ------- .../dashboard/recent-operations.blade.php | 37 ------- .../TenantDashboardTenantScopeTest.php | 9 +- 9 files changed, 256 insertions(+), 190 deletions(-) delete mode 100644 resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php delete mode 100644 resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php delete mode 100644 resources/views/filament/widgets/dashboard/recent-operations.blade.php diff --git a/app/Filament/Widgets/Dashboard/DashboardKpis.php b/app/Filament/Widgets/Dashboard/DashboardKpis.php index 6df636d..9dc3cc5 100644 --- a/app/Filament/Widgets/Dashboard/DashboardKpis.php +++ b/app/Filament/Widgets/Dashboard/DashboardKpis.php @@ -4,35 +4,46 @@ namespace App\Filament\Widgets\Dashboard; +use App\Filament\Resources\FindingResource; +use App\Filament\Resources\OperationRunResource; 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; +use Filament\Widgets\StatsOverviewWidget; +use Filament\Widgets\StatsOverviewWidget\Stat; -class DashboardKpis extends Widget +class DashboardKpis extends StatsOverviewWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.dashboard.dashboard-kpis'; - protected int|string|array $columnSpan = 'full'; + protected function getPollingInterval(): ?string + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return ActiveRuns::existForTenant($tenant) ? '10s' : null; + } + /** - * @return array + * @return array */ - protected function getViewData(): array + protected function getStats(): array { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return [ - 'pollingInterval' => null, - 'openDriftFindings' => 0, - 'highSeverityDriftFindings' => 0, - 'activeRuns' => 0, - 'inventoryActiveRuns' => 0, + Stat::make('Open drift findings', 0), + Stat::make('High severity drift', 0), + Stat::make('Active operations', 0), + Stat::make('Inventory active', 0), ]; } @@ -63,11 +74,17 @@ protected function getViewData(): array ->count(); return [ - 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, - 'openDriftFindings' => $openDriftFindings, - 'highSeverityDriftFindings' => $highSeverityDriftFindings, - 'activeRuns' => $activeRuns, - 'inventoryActiveRuns' => $inventoryActiveRuns, + Stat::make('Open drift findings', $openDriftFindings) + ->url(FindingResource::getUrl('index', tenant: $tenant)), + Stat::make('High severity drift', $highSeverityDriftFindings) + ->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray') + ->url(FindingResource::getUrl('index', tenant: $tenant)), + Stat::make('Active operations', $activeRuns) + ->color($activeRuns > 0 ? 'warning' : 'gray') + ->url(OperationRunResource::getUrl('index', tenant: $tenant)), + Stat::make('Inventory active', $inventoryActiveRuns) + ->color($inventoryActiveRuns > 0 ? 'warning' : 'gray') + ->url(OperationRunResource::getUrl('index', tenant: $tenant)), ]; } } diff --git a/app/Filament/Widgets/Dashboard/NeedsAttention.php b/app/Filament/Widgets/Dashboard/NeedsAttention.php index 4c7b76d..6539c1d 100644 --- a/app/Filament/Widgets/Dashboard/NeedsAttention.php +++ b/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -31,6 +31,7 @@ protected function getViewData(): array return [ 'pollingInterval' => null, 'items' => [], + 'healthyChecks' => [], ]; } @@ -50,6 +51,8 @@ protected function getViewData(): array 'title' => 'High severity drift findings', 'body' => "{$highSeverityCount} finding(s) need review.", 'url' => FindingResource::getUrl('index', tenant: $tenant), + 'badge' => 'Drift', + 'badgeColor' => 'danger', ]; } @@ -67,6 +70,8 @@ protected function getViewData(): array 'title' => 'No drift scan yet', 'body' => 'Generate drift after you have at least two successful inventory runs.', 'url' => DriftLanding::getUrl(tenant: $tenant), + 'badge' => 'Drift', + 'badgeColor' => 'warning', ]; } else { $isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true; @@ -76,6 +81,8 @@ protected function getViewData(): array 'title' => 'Drift stale', 'body' => 'Last drift scan is older than 7 days.', 'url' => DriftLanding::getUrl(tenant: $tenant), + 'badge' => 'Drift', + 'badgeColor' => 'warning', ]; } } @@ -93,6 +100,8 @@ protected function getViewData(): array 'title' => 'Drift generation failed', 'body' => 'Investigate the latest failed run.', 'url' => OperationRunLinks::view($latestDriftFailure, $tenant), + 'badge' => 'Operations', + 'badgeColor' => 'danger', ]; } @@ -106,12 +115,44 @@ protected function getViewData(): array 'title' => 'Operations in progress', 'body' => "{$activeRuns} run(s) are active.", 'url' => OperationRunLinks::index($tenant), + 'badge' => 'Operations', + 'badgeColor' => 'warning', + ]; + } + + $items = array_slice($items, 0, 5); + + $healthyChecks = []; + + if ($items === []) { + $healthyChecks = [ + [ + 'title' => 'Drift findings look healthy', + 'body' => 'No high severity drift findings are open.', + 'url' => FindingResource::getUrl('index', tenant: $tenant), + 'linkLabel' => 'View findings', + ], + [ + 'title' => 'Drift scans are up to date', + 'body' => $latestDriftSuccess?->completed_at + ? 'Last drift scan: '.$latestDriftSuccess->completed_at->diffForHumans(['short' => true]).'.' + : 'Drift scan history is available in Drift.', + 'url' => DriftLanding::getUrl(tenant: $tenant), + 'linkLabel' => 'Open Drift', + ], + [ + 'title' => 'No active operations', + 'body' => 'Nothing is currently running for this tenant.', + 'url' => OperationRunLinks::index($tenant), + 'linkLabel' => 'View operations', + ], ]; } return [ 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, 'items' => $items, + 'healthyChecks' => $healthyChecks, ]; } } diff --git a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php index 8483da0..448046a 100644 --- a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php +++ b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php @@ -4,46 +4,80 @@ namespace App\Filament\Widgets\Dashboard; +use App\Filament\Resources\FindingResource; use App\Models\Finding; +use App\Models\InventoryItem; use App\Models\Tenant; use App\Support\OpsUx\ActiveRuns; use Filament\Facades\Filament; -use Filament\Widgets\Widget; -use Illuminate\Support\Collection; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; +use Filament\Widgets\TableWidget; +use Illuminate\Database\Eloquent\Builder; -class RecentDriftFindings extends Widget +class RecentDriftFindings extends TableWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.dashboard.recent-drift-findings'; - - /** - * @return array - */ - protected function getViewData(): array + public function table(Table $table): Table { $tenant = Filament::getTenant(); - if (! $tenant instanceof Tenant) { - return [ - 'pollingInterval' => null, - 'findings' => collect(), - ]; - } + return $table + ->heading('Recent Drift Findings') + ->query($this->getQuery()) + ->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null) + ->paginated([10]) + ->columns([ + TextColumn::make('short_id') + ->label('ID') + ->state(fn (Finding $record): string => '#'.$record->getKey()) + ->copyable() + ->copyableState(fn (Finding $record): string => (string) $record->getKey()), + TextColumn::make('subject_display_name') + ->label('Subject') + ->placeholder('—') + ->limit(40) + ->tooltip(fn (Finding $record): ?string => $record->subject_display_name ?: null), + TextColumn::make('severity') + ->badge() + ->color(fn (Finding $record): string => match ($record->severity) { + Finding::SEVERITY_HIGH => 'danger', + Finding::SEVERITY_MEDIUM => 'warning', + default => 'gray', + }), + TextColumn::make('status') + ->badge() + ->color(fn (Finding $record): string => $record->status === Finding::STATUS_NEW ? 'warning' : 'gray'), + TextColumn::make('created_at') + ->label('Created') + ->since(), + ]) + ->recordUrl(fn (Finding $record): ?string => $tenant instanceof Tenant + ? FindingResource::getUrl('view', ['record' => $record], tenant: $tenant) + : null) + ->emptyStateHeading('No drift findings') + ->emptyStateDescription('You\'re looking good — no drift findings to review yet.'); + } - $tenantId = (int) $tenant->getKey(); + /** + * @return Builder + */ + private function getQuery(): Builder + { + $tenant = Filament::getTenant(); + $tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null; - /** @var Collection $findings */ - $findings = Finding::query() - ->where('tenant_id', $tenantId) + return Finding::query() + ->addSelect([ + 'subject_display_name' => InventoryItem::query() + ->select('display_name') + ->whereColumn('inventory_items.tenant_id', 'findings.tenant_id') + ->whereColumn('inventory_items.external_id', 'findings.subject_external_id') + ->limit(1), + ]) + ->when($tenantId, fn (Builder $query) => $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, - ]; + ->latest('created_at'); } } diff --git a/app/Filament/Widgets/Dashboard/RecentOperations.php b/app/Filament/Widgets/Dashboard/RecentOperations.php index a5640b6..9c9428e 100644 --- a/app/Filament/Widgets/Dashboard/RecentOperations.php +++ b/app/Filament/Widgets/Dashboard/RecentOperations.php @@ -10,48 +10,85 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\ActiveRuns; use Filament\Facades\Filament; -use Filament\Widgets\Widget; -use Illuminate\Support\Collection; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; +use Filament\Widgets\TableWidget; +use Illuminate\Database\Eloquent\Builder; -class RecentOperations extends Widget +class RecentOperations extends TableWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.dashboard.recent-operations'; - protected int|string|array $columnSpan = 'full'; - /** - * @return array - */ - protected function getViewData(): array + public function table(Table $table): Table { $tenant = Filament::getTenant(); - if (! $tenant instanceof Tenant) { - return [ - 'pollingInterval' => null, - 'runs' => collect(), - 'viewRunBaseUrl' => null, - ]; - } + return $table + ->heading('Recent Operations') + ->query($this->getQuery()) + ->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null) + ->paginated([10]) + ->columns([ + TextColumn::make('short_id') + ->label('Run') + ->state(fn (OperationRun $record): string => '#'.$record->getKey()) + ->copyable() + ->copyableState(fn (OperationRun $record): string => (string) $record->getKey()), + TextColumn::make('type') + ->label('Operation') + ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) + ->limit(40) + ->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)), + TextColumn::make('status') + ->badge() + ->color(fn (OperationRun $record): string => $this->statusColor($record->status)), + TextColumn::make('outcome') + ->badge() + ->color(fn (OperationRun $record): string => $this->outcomeColor($record->outcome)), + TextColumn::make('created_at') + ->label('Started') + ->since(), + ]) + ->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant + ? OperationRunLinks::view($record, $tenant) + : null) + ->emptyStateHeading('No operations yet') + ->emptyStateDescription('Once you run inventory sync, drift generation, or restores, they\'ll show up here.'); + } - $tenantId = (int) $tenant->getKey(); + /** + * @return Builder + */ + private function getQuery(): Builder + { + $tenant = Filament::getTenant(); + $tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null; - /** @var Collection $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 OperationRun::query() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) + ->latest('created_at'); + } - return [ - 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, - 'runs' => $runs, - ]; + private function statusColor(?string $status): string + { + return match ($status) { + 'queued' => 'secondary', + 'running' => 'warning', + 'completed' => 'success', + default => 'gray', + }; + } + + private function outcomeColor(?string $outcome): string + { + return match ($outcome) { + 'succeeded' => 'success', + 'partially_succeeded' => 'warning', + 'failed' => 'danger', + 'cancelled' => 'gray', + default => 'gray', + }; } } diff --git a/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php b/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php deleted file mode 100644 index da6c92a..0000000 --- a/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -
-
-
-
Open drift findings
-
{{ $openDriftFindings }}
-
- -
-
High severity drift
-
{{ $highSeverityDriftFindings }}
-
- -
-
Active operations
-
{{ $activeRuns }}
-
- -
-
Inventory active
-
{{ $inventoryActiveRuns }}
-
-
-
diff --git a/resources/views/filament/widgets/dashboard/needs-attention.blade.php b/resources/views/filament/widgets/dashboard/needs-attention.blade.php index e450088..de0026b 100644 --- a/resources/views/filament/widgets/dashboard/needs-attention.blade.php +++ b/resources/views/filament/widgets/dashboard/needs-attention.blade.php @@ -2,25 +2,54 @@ @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" + class="flex flex-col gap-4" > -
-
Needs Attention
+
Needs Attention
+ + @if (count($items) === 0) +
+
+ Everything looks healthy right now. +
- @if (count($items) === 0) -
Nothing urgent right now.
- @else
- @foreach ($items as $item) - -
{{ $item['title'] }}
-
{{ $item['body'] }}
-
+ @foreach ($healthyChecks as $check) +
+ + +
+
{{ $check['title'] }}
+
{{ $check['body'] }}
+ +
+ + {{ $check['linkLabel'] }} + +
+
+
@endforeach
- @endif -
+
+ @else + + @endif diff --git a/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php b/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php deleted file mode 100644 index ca3b325..0000000 --- a/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php +++ /dev/null @@ -1,34 +0,0 @@ -
-
-
Recent Drift Findings
-
- -
- @if ($findings->isEmpty()) -
No drift findings yet.
- @else -
- @foreach ($findings as $finding) -
-
-
- {{ $finding->subject_type }} · {{ $finding->subject_external_id }} -
-
- {{ $finding->created_at?->diffForHumans() }} -
-
-
- Severity: {{ $finding->severity }} · Status: {{ $finding->status }} -
-
- @endforeach -
- @endif -
-
diff --git a/resources/views/filament/widgets/dashboard/recent-operations.blade.php b/resources/views/filament/widgets/dashboard/recent-operations.blade.php deleted file mode 100644 index 8aaa690..0000000 --- a/resources/views/filament/widgets/dashboard/recent-operations.blade.php +++ /dev/null @@ -1,37 +0,0 @@ -
-
-
Recent Operations
-
- - -
diff --git a/tests/Feature/Filament/TenantDashboardTenantScopeTest.php b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php index 9f6310c..1d61da5 100644 --- a/tests/Feature/Filament/TenantDashboardTenantScopeTest.php +++ b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php @@ -4,6 +4,7 @@ use App\Filament\Pages\TenantDashboard; use App\Models\Finding; +use App\Models\InventoryItem; use App\Models\OperationRun; use App\Models\Tenant; @@ -11,6 +12,12 @@ [$user, $tenant] = createUserWithTenant(role: 'owner'); $otherTenant = Tenant::factory()->create(); + InventoryItem::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'external_id' => 'other-tenant-finding', + 'display_name' => 'Other Tenant Policy', + ]); + Finding::factory()->create([ 'tenant_id' => $otherTenant->getKey(), 'finding_type' => Finding::FINDING_TYPE_DRIFT, @@ -29,5 +36,5 @@ $this->get(TenantDashboard::getUrl(tenant: $tenant)) ->assertOk() - ->assertDontSee('other-tenant-finding'); + ->assertDontSee('Other Tenant Policy'); }); -- 2.45.2 From 6924a0822694c531662fc827d5b895ccd8a870c8 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 22 Jan 2026 01:03:59 +0100 Subject: [PATCH 5/5] feat: inventory + operations UI polish --- app/Filament/Pages/InventoryCoverage.php | 2 + app/Filament/Pages/InventoryLanding.php | 244 +----------------- .../Resources/InventoryItemResource.php | 2 + .../Pages/ListInventoryItems.php | 222 ++++++++++++++++ .../Resources/InventorySyncRunResource.php | 7 + .../Widgets/Inventory/InventoryKpiHeader.php | 91 ++++--- .../Operations/OperationsKpiHeader.php | 40 +-- app/Support/Inventory/InventoryKpiBadges.php | 46 ++++ .../Inventory/InventorySyncStatusBadge.php | 55 ++++ .../inventory/inventory-kpi-header.blade.php | 36 --- .../operations-kpi-header.blade.php | 28 -- specs/058-tenant-ui-polish/tasks.md | 32 +-- .../Filament/InventoryHubDbOnlyTest.php | 6 +- tests/Feature/Filament/InventoryPagesTest.php | 21 +- .../Inventory/InventorySyncButtonTest.php | 18 +- .../InventorySyncStartSurfaceTest.php | 4 +- tests/Feature/RunStartAuthorizationTest.php | 4 +- 17 files changed, 459 insertions(+), 399 deletions(-) create mode 100644 app/Support/Inventory/InventoryKpiBadges.php create mode 100644 app/Support/Inventory/InventorySyncStatusBadge.php delete mode 100644 resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php delete mode 100644 resources/views/filament/widgets/operations/operations-kpi-header.blade.php diff --git a/app/Filament/Pages/InventoryCoverage.php b/app/Filament/Pages/InventoryCoverage.php index 7b014ff..0fff310 100644 --- a/app/Filament/Pages/InventoryCoverage.php +++ b/app/Filament/Pages/InventoryCoverage.php @@ -14,6 +14,8 @@ class InventoryCoverage extends Page { protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells'; + protected static ?int $navigationSort = 3; + protected static string|UnitEnum|null $navigationGroup = 'Inventory'; protected static ?string $navigationLabel = 'Coverage'; diff --git a/app/Filament/Pages/InventoryLanding.php b/app/Filament/Pages/InventoryLanding.php index 3e6f33d..07c3ef4 100644 --- a/app/Filament/Pages/InventoryLanding.php +++ b/app/Filament/Pages/InventoryLanding.php @@ -4,32 +4,16 @@ use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Resources\InventoryItemResource; -use App\Filament\Resources\InventorySyncRunResource; use App\Filament\Widgets\Inventory\InventoryKpiHeader; -use App\Jobs\RunInventorySyncJob; -use App\Models\InventorySyncRun; use App\Models\Tenant; -use App\Models\User; -use App\Services\Intune\AuditLogger; -use App\Services\Inventory\InventorySyncService; -use App\Services\OperationRunService; -use App\Support\Inventory\InventoryPolicyTypeMeta; -use App\Support\OperationRunLinks; -use App\Support\OpsUx\OperationUxPresenter; -use App\Support\OpsUx\OpsUxBrowserEvents; use BackedEnum; -use Filament\Actions\Action; -use Filament\Actions\Action as HintAction; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\Toggle; -use Filament\Notifications\Notification; use Filament\Pages\Page; -use Filament\Support\Enums\Size; use UnitEnum; class InventoryLanding extends Page { + protected static bool $shouldRegisterNavigation = false; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; @@ -40,229 +24,15 @@ class InventoryLanding extends Page protected string $view = 'filament.pages.inventory-landing'; + public function mount(): void + { + $this->redirect(InventoryItemResource::getUrl('index', tenant: Tenant::current())); + } + protected function getHeaderWidgets(): array { return [ InventoryKpiHeader::class, ]; } - - protected function getHeaderActions(): array - { - return [ - Action::make('run_inventory_sync') - ->label('Run Inventory Sync') - ->icon('heroicon-o-arrow-path') - ->color('warning') - ->form([ - Select::make('policy_types') - ->label('Policy types') - ->multiple() - ->searchable() - ->preload() - ->native(false) - ->hintActions([ - fn (Select $component): HintAction => HintAction::make('select_all_policy_types') - ->label('Select all') - ->link() - ->size(Size::Small) - ->action(function (InventorySyncService $inventorySyncService) use ($component): void { - $component->state($inventorySyncService->defaultSelectionPayload()['policy_types']); - }), - fn (Select $component): HintAction => HintAction::make('clear_policy_types') - ->label('Clear') - ->link() - ->size(Size::Small) - ->action(function () use ($component): void { - $component->state([]); - }), - ]) - ->options(function (): array { - return collect(InventoryPolicyTypeMeta::supported()) - ->filter(fn (array $meta): bool => filled($meta['type'] ?? null)) - ->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other')) - ->mapWithKeys(function ($items, string $category): array { - $options = collect($items) - ->mapWithKeys(function (array $meta): array { - $type = (string) $meta['type']; - $label = (string) ($meta['label'] ?? $type); - $platform = (string) ($meta['platform'] ?? 'all'); - - return [$type => "{$label} • {$platform}"]; - }) - ->all(); - - return [$category => $options]; - }) - ->all(); - }) - ->columnSpanFull(), - Toggle::make('include_foundations') - ->label('Include foundation types') - ->helperText('Include scope tags, assignment filters, and notification templates.') - ->default(true) - ->dehydrated() - ->rules(['boolean']) - ->columnSpanFull(), - Toggle::make('include_dependencies') - ->label('Include dependencies') - ->helperText('Include dependency extraction where supported.') - ->default(true) - ->dehydrated() - ->rules(['boolean']) - ->columnSpanFull(), - Hidden::make('tenant_id') - ->default(fn (): ?string => Tenant::current()?->getKey()) - ->dehydrated(), - ]) - ->visible(function (): bool { - $user = auth()->user(); - if (! $user instanceof User) { - return false; - } - - return $user->canSyncTenant(Tenant::current()); - }) - ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { - $tenant = Tenant::current(); - - $user = auth()->user(); - if (! $user instanceof User) { - abort(403, 'Not allowed'); - } - - if (! $user->canSyncTenant($tenant)) { - abort(403, 'Not allowed'); - } - - $requestedTenantId = $data['tenant_id'] ?? null; - if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) { - Notification::make() - ->title('Not allowed') - ->danger() - ->send(); - - abort(403, 'Not allowed'); - } - - $selectionPayload = $inventorySyncService->defaultSelectionPayload(); - if (array_key_exists('policy_types', $data)) { - $selectionPayload['policy_types'] = $data['policy_types']; - } - if (array_key_exists('include_foundations', $data)) { - $selectionPayload['include_foundations'] = (bool) $data['include_foundations']; - } - if (array_key_exists('include_dependencies', $data)) { - $selectionPayload['include_dependencies'] = (bool) $data['include_dependencies']; - } - $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); - - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRun( - tenant: $tenant, - type: 'inventory.sync', - inputs: $computed['selection'], - initiator: $user - ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - Notification::make() - ->title('Inventory sync already active') - ->body('This operation is already queued or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - - return; - } - - // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) - $existing = InventorySyncRun::query() - ->where('tenant_id', $tenant->getKey()) - ->where('selection_hash', $computed['selection_hash']) - ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) - ->first(); - - // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. - if ($existing instanceof InventorySyncRun) { - Notification::make() - ->title('Inventory sync already active') - ->body('A matching inventory sync run is already pending or running.') - ->warning() - ->actions([ - Action::make('view_run') - ->label('View Run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; - } - - $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); - - $policyTypes = $computed['selection']['policy_types'] ?? []; - if (! is_array($policyTypes)) { - $policyTypes = []; - } - - $auditLogger->log( - tenant: $tenant, - action: 'inventory.sync.dispatched', - context: [ - 'metadata' => [ - 'inventory_sync_run_id' => $run->id, - 'selection_hash' => $run->selection_hash, - ], - ], - actorId: $user->id, - actorEmail: $user->email, - actorName: $user->name, - resourceType: 'inventory_sync_run', - resourceId: (string) $run->id, - ); - - $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { - RunInventorySyncJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - inventorySyncRunId: (int) $run->id, - operationRun: $opRun - ); - }); - - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Action::make('view_run') - ->label('View run') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - }), - ]; - } - - public function getInventoryItemsUrl(): string - { - return InventoryItemResource::getUrl('index', tenant: Tenant::current()); - } - - public function getSyncRunsUrl(): string - { - return InventorySyncRunResource::getUrl('index', tenant: Tenant::current()); - } - - public function getCoverageUrl(): string - { - return InventoryCoverage::getUrl(tenant: Tenant::current()); - } } diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 538d7bf..3c342dd 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -28,6 +28,8 @@ class InventoryItemResource extends Resource protected static ?string $cluster = InventoryCluster::class; + protected static ?int $navigationSort = 1; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index 6792b35..d37abf9 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -4,7 +4,25 @@ use App\Filament\Resources\InventoryItemResource; use App\Filament\Widgets\Inventory\InventoryKpiHeader; +use App\Jobs\RunInventorySyncJob; +use App\Models\InventorySyncRun; +use App\Models\Tenant; +use App\Models\User; +use App\Services\Intune\AuditLogger; +use App\Services\Inventory\InventorySyncService; +use App\Services\OperationRunService; +use App\Support\Inventory\InventoryPolicyTypeMeta; +use App\Support\OperationRunLinks; +use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\OpsUxBrowserEvents; +use Filament\Actions\Action; +use Filament\Actions\Action as HintAction; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Toggle; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; +use Filament\Support\Enums\Size; class ListInventoryItems extends ListRecords { @@ -16,4 +34,208 @@ protected function getHeaderWidgets(): array InventoryKpiHeader::class, ]; } + + protected function getHeaderActions(): array + { + return [ + Action::make('run_inventory_sync') + ->label('Run Inventory Sync') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->form([ + Select::make('policy_types') + ->label('Policy types') + ->multiple() + ->searchable() + ->preload() + ->native(false) + ->hintActions([ + fn (Select $component): HintAction => HintAction::make('select_all_policy_types') + ->label('Select all') + ->link() + ->size(Size::Small) + ->action(function (InventorySyncService $inventorySyncService) use ($component): void { + $component->state($inventorySyncService->defaultSelectionPayload()['policy_types']); + }), + fn (Select $component): HintAction => HintAction::make('clear_policy_types') + ->label('Clear') + ->link() + ->size(Size::Small) + ->action(function () use ($component): void { + $component->state([]); + }), + ]) + ->options(function (): array { + return collect(InventoryPolicyTypeMeta::supported()) + ->filter(fn (array $meta): bool => filled($meta['type'] ?? null)) + ->groupBy(fn (array $meta): string => (string) ($meta['category'] ?? 'Other')) + ->mapWithKeys(function ($items, string $category): array { + $options = collect($items) + ->mapWithKeys(function (array $meta): array { + $type = (string) $meta['type']; + $label = (string) ($meta['label'] ?? $type); + $platform = (string) ($meta['platform'] ?? 'all'); + + return [$type => "{$label} • {$platform}"]; + }) + ->all(); + + return [$category => $options]; + }) + ->all(); + }) + ->columnSpanFull(), + Toggle::make('include_foundations') + ->label('Include foundation types') + ->helperText('Include scope tags, assignment filters, and notification templates.') + ->default(true) + ->dehydrated() + ->rules(['boolean']) + ->columnSpanFull(), + Toggle::make('include_dependencies') + ->label('Include dependencies') + ->helperText('Include dependency extraction where supported.') + ->default(true) + ->dehydrated() + ->rules(['boolean']) + ->columnSpanFull(), + Hidden::make('tenant_id') + ->default(fn (): ?string => Tenant::current()?->getKey()) + ->dehydrated(), + ]) + ->visible(function (): bool { + $user = auth()->user(); + if (! $user instanceof User) { + return false; + } + + return $user->canSyncTenant(Tenant::current()); + }) + ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { + $tenant = Tenant::current(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $user->canSyncTenant($tenant)) { + abort(403, 'Not allowed'); + } + + $requestedTenantId = $data['tenant_id'] ?? null; + if ($requestedTenantId !== null && (int) $requestedTenantId !== (int) $tenant->getKey()) { + Notification::make() + ->title('Not allowed') + ->danger() + ->send(); + + abort(403, 'Not allowed'); + } + + $selectionPayload = $inventorySyncService->defaultSelectionPayload(); + if (array_key_exists('policy_types', $data)) { + $selectionPayload['policy_types'] = $data['policy_types']; + } + if (array_key_exists('include_foundations', $data)) { + $selectionPayload['include_foundations'] = (bool) $data['include_foundations']; + } + if (array_key_exists('include_dependencies', $data)) { + $selectionPayload['include_dependencies'] = (bool) $data['include_dependencies']; + } + $computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload); + + /** @var OperationRunService $opService */ + $opService = app(OperationRunService::class); + $opRun = $opService->ensureRun( + tenant: $tenant, + type: 'inventory.sync', + inputs: $computed['selection'], + initiator: $user + ); + + if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { + Notification::make() + ->title('Inventory sync already active') + ->body('This operation is already queued or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + + return; + } + + // Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now) + $existing = InventorySyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_hash', $computed['selection_hash']) + ->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING]) + ->first(); + + // If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe. + if ($existing instanceof InventorySyncRun) { + Notification::make() + ->title('Inventory sync already active') + ->body('A matching inventory sync run is already pending or running.') + ->warning() + ->actions([ + Action::make('view_run') + ->label('View Run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + return; + } + + $run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']); + + $policyTypes = $computed['selection']['policy_types'] ?? []; + if (! is_array($policyTypes)) { + $policyTypes = []; + } + + $auditLogger->log( + tenant: $tenant, + action: 'inventory.sync.dispatched', + context: [ + 'metadata' => [ + 'inventory_sync_run_id' => $run->id, + 'selection_hash' => $run->selection_hash, + ], + ], + actorId: $user->id, + actorEmail: $user->email, + actorName: $user->name, + resourceType: 'inventory_sync_run', + resourceId: (string) $run->id, + ); + + $opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void { + RunInventorySyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + inventorySyncRunId: (int) $run->id, + operationRun: $opRun + ); + }); + + OperationUxPresenter::queuedToast((string) $opRun->type) + ->actions([ + Action::make('view_run') + ->label('View run') + ->url(OperationRunLinks::view($opRun, $tenant)), + ]) + ->send(); + + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); + }), + ]; + } } diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index 1716a8e..5f32f5c 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -27,10 +27,17 @@ class InventorySyncRunResource extends Resource protected static ?string $cluster = InventoryCluster::class; + protected static ?int $navigationSort = 2; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock'; protected static string|UnitEnum|null $navigationGroup = 'Inventory'; + public static function getNavigationLabel(): string + { + return 'Sync History'; + } + public static function form(Schema $schema): Schema { return $schema; diff --git a/app/Filament/Widgets/Inventory/InventoryKpiHeader.php b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php index 7d8352c..f4128ea 100644 --- a/app/Filament/Widgets/Inventory/InventoryKpiHeader.php +++ b/app/Filament/Widgets/Inventory/InventoryKpiHeader.php @@ -4,21 +4,25 @@ namespace App\Filament\Widgets\Inventory; +use App\Filament\Resources\InventorySyncRunResource; use App\Models\InventoryItem; use App\Models\InventorySyncRun; use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Inventory\CoverageCapabilitiesResolver; +use App\Support\Inventory\InventoryKpiBadges; use App\Support\Inventory\InventoryPolicyTypeMeta; +use App\Support\Inventory\InventorySyncStatusBadge; use Filament\Facades\Filament; -use Filament\Widgets\Widget; +use Filament\Widgets\StatsOverviewWidget; +use Filament\Widgets\StatsOverviewWidget\Stat; +use Illuminate\Support\Facades\Blade; +use Illuminate\Support\HtmlString; -class InventoryKpiHeader extends Widget +class InventoryKpiHeader extends StatsOverviewWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.inventory.inventory-kpi-header'; - protected int|string|array $columnSpan = 'full'; /** @@ -27,23 +31,19 @@ class InventoryKpiHeader extends Widget * - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`) * - dependency capability via `CoverageCapabilitiesResolver` * - * @return array + * @return array */ - protected function getViewData(): array + protected function getStats(): array { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return [ - 'totalItems' => 0, - 'coveragePercent' => 0, - 'lastInventorySyncLabel' => '—', - 'activeOps' => 0, - 'inventoryOps' => 0, - 'dependenciesItems' => 0, - 'partialItems' => 0, - 'restorableItems' => 0, - 'riskItems' => 0, + Stat::make('Total items', 0), + Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'), + Stat::make('Last inventory sync', '—'), + Stat::make('Active ops', 0), + Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'), ]; } @@ -85,17 +85,49 @@ protected function getViewData(): array ->latest('id') ->first(); - $lastInventorySyncLabel = '—'; + $lastInventorySyncTimeLabel = '—'; + $lastInventorySyncStatusLabel = '—'; + $lastInventorySyncStatusColor = 'gray'; + $lastInventorySyncStatusIcon = 'heroicon-m-clock'; + $lastInventorySyncViewUrl = null; + if ($lastRun instanceof InventorySyncRun) { $timestamp = $lastRun->finished_at ?? $lastRun->started_at; - $lastInventorySyncLabel = trim(sprintf( - '%s%s', - (string) ($lastRun->status ?? '—'), - $timestamp ? ' • '.$timestamp->diffForHumans() : '' - )); + if ($timestamp) { + $lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]); + } + + $status = (string) ($lastRun->status ?? ''); + + $badge = InventorySyncStatusBadge::for($status); + $lastInventorySyncStatusLabel = $badge['label']; + $lastInventorySyncStatusColor = $badge['color']; + $lastInventorySyncStatusIcon = $badge['icon']; + + $lastInventorySyncViewUrl = InventorySyncRunResource::getUrl('view', ['record' => $lastRun], tenant: $tenant); } + $badgeColor = $lastInventorySyncStatusColor; + + $lastInventorySyncDescription = Blade::render(<<<'BLADE' +
+ + {{ $statusLabel }} + + + @if ($viewUrl) + + View run + + @endif +
+ BLADE, [ + 'badgeColor' => $badgeColor, + 'statusLabel' => $lastInventorySyncStatusLabel, + 'viewUrl' => $lastInventorySyncViewUrl, + ]); + $activeOps = (int) OperationRun::query() ->where('tenant_id', $tenantId) ->active() @@ -117,15 +149,14 @@ protected function getViewData(): array } return [ - 'totalItems' => $totalItems, - 'coveragePercent' => $coveragePercent, - 'lastInventorySyncLabel' => $lastInventorySyncLabel, - 'activeOps' => $activeOps, - 'inventoryOps' => $inventoryOps, - 'dependenciesItems' => $dependenciesItems, - 'partialItems' => $partialItems, - 'restorableItems' => $restorableItems, - 'riskItems' => $riskItems, + Stat::make('Total items', $totalItems), + Stat::make('Coverage', $coveragePercent.'%') + ->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))), + Stat::make('Last inventory sync', $lastInventorySyncTimeLabel) + ->description(new HtmlString($lastInventorySyncDescription)), + Stat::make('Active ops', $activeOps), + Stat::make('Inventory ops', $inventoryOps) + ->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))), ]; } } diff --git a/app/Filament/Widgets/Operations/OperationsKpiHeader.php b/app/Filament/Widgets/Operations/OperationsKpiHeader.php index 50b02e3..0ba00a8 100644 --- a/app/Filament/Widgets/Operations/OperationsKpiHeader.php +++ b/app/Filament/Widgets/Operations/OperationsKpiHeader.php @@ -11,31 +11,40 @@ use App\Support\OpsUx\ActiveRuns; use Carbon\CarbonInterval; use Filament\Facades\Filament; -use Filament\Widgets\Widget; +use Filament\Widgets\StatsOverviewWidget; +use Filament\Widgets\StatsOverviewWidget\Stat; use Illuminate\Support\Collection; -class OperationsKpiHeader extends Widget +class OperationsKpiHeader extends StatsOverviewWidget { protected static bool $isLazy = false; - protected string $view = 'filament.widgets.operations.operations-kpi-header'; - protected int|string|array $columnSpan = 'full'; + protected function getPollingInterval(): ?string + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return ActiveRuns::existForTenant($tenant) ? '10s' : null; + } + /** - * @return array + * @return array */ - protected function getViewData(): array + protected function getStats(): array { $tenant = Filament::getTenant(); if (! $tenant instanceof Tenant) { return [ - 'pollingInterval' => null, - 'totalRuns30Days' => 0, - 'activeRuns' => 0, - 'failedOrPartial7Days' => 0, - 'avgDuration7Days' => '—', + Stat::make('Total Runs (30 days)', 0), + Stat::make('Active Runs', 0), + Stat::make('Failed/Partial (7 days)', 0), + Stat::make('Avg Duration (7 days)', '—'), ]; } @@ -99,11 +108,10 @@ protected function getViewData(): array } return [ - 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, - 'totalRuns30Days' => $totalRuns30Days, - 'activeRuns' => $activeRuns, - 'failedOrPartial7Days' => $failedOrPartial7Days, - 'avgDuration7Days' => $avgDuration7Days, + Stat::make('Total Runs (30 days)', $totalRuns30Days), + Stat::make('Active Runs', $activeRuns), + Stat::make('Failed/Partial (7 days)', $failedOrPartial7Days), + Stat::make('Avg Duration (7 days)', $avgDuration7Days), ]; } diff --git a/app/Support/Inventory/InventoryKpiBadges.php b/app/Support/Inventory/InventoryKpiBadges.php new file mode 100644 index 0000000..24d67f4 --- /dev/null +++ b/app/Support/Inventory/InventoryKpiBadges.php @@ -0,0 +1,46 @@ + + + Restorable {{ $restorableCount }} + + + + Partial {{ $partialCount }} + + + BLADE, [ + 'restorableCount' => $restorableCount, + 'partialCount' => $partialCount, + ]); + } + + public static function inventoryOps(int $dependenciesCount, int $riskCount): string + { + return Blade::render(<<<'BLADE' +
+ + Dependencies {{ $dependenciesCount }} + + + + Risk {{ $riskCount }} + +
+ BLADE, [ + 'dependenciesCount' => $dependenciesCount, + 'riskCount' => $riskCount, + ]); + } +} diff --git a/app/Support/Inventory/InventorySyncStatusBadge.php b/app/Support/Inventory/InventorySyncStatusBadge.php new file mode 100644 index 0000000..ffc18ed --- /dev/null +++ b/app/Support/Inventory/InventorySyncStatusBadge.php @@ -0,0 +1,55 @@ + 'Success', + InventorySyncRun::STATUS_PARTIAL => 'Partial', + InventorySyncRun::STATUS_FAILED => 'Failed', + InventorySyncRun::STATUS_RUNNING => 'Running', + InventorySyncRun::STATUS_PENDING => 'Pending', + InventorySyncRun::STATUS_SKIPPED => 'Skipped', + 'queued' => 'Queued', + default => '—', + }; + + $color = match ($status) { + InventorySyncRun::STATUS_SUCCESS => 'success', + InventorySyncRun::STATUS_PARTIAL => 'warning', + InventorySyncRun::STATUS_FAILED => 'danger', + InventorySyncRun::STATUS_RUNNING => 'info', + InventorySyncRun::STATUS_PENDING, 'queued' => 'gray', + InventorySyncRun::STATUS_SKIPPED => 'gray', + default => 'gray', + }; + + $icon = match ($status) { + InventorySyncRun::STATUS_SUCCESS => 'heroicon-m-check-circle', + InventorySyncRun::STATUS_PARTIAL => 'heroicon-m-exclamation-triangle', + InventorySyncRun::STATUS_FAILED => 'heroicon-m-x-circle', + InventorySyncRun::STATUS_RUNNING => 'heroicon-m-arrow-path', + InventorySyncRun::STATUS_PENDING, 'queued' => 'heroicon-m-clock', + InventorySyncRun::STATUS_SKIPPED => 'heroicon-m-minus-circle', + default => 'heroicon-m-clock', + }; + + return [ + 'label' => $label, + 'color' => $color, + 'icon' => $icon, + ]; + } +} diff --git a/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php b/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php deleted file mode 100644 index aa11864..0000000 --- a/resources/views/filament/widgets/inventory/inventory-kpi-header.blade.php +++ /dev/null @@ -1,36 +0,0 @@ -
-
-
-
Total items
-
{{ $totalItems }}
-
- -
-
Coverage
-
{{ $coveragePercent }}%
-
- Restorable {{ $restorableItems }} - Partial {{ $partialItems }} -
-
- -
-
Last inventory sync
-
{{ $lastInventorySyncLabel }}
-
- -
-
Active ops
-
{{ $activeOps }}
-
- -
-
Inventory ops
-
{{ $inventoryOps }}
-
- Dependencies {{ $dependenciesItems }} - Risk {{ $riskItems }} -
-
-
-
diff --git a/resources/views/filament/widgets/operations/operations-kpi-header.blade.php b/resources/views/filament/widgets/operations/operations-kpi-header.blade.php deleted file mode 100644 index 3ff4b1c..0000000 --- a/resources/views/filament/widgets/operations/operations-kpi-header.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -
-
-
-
Total Runs (30 days)
-
{{ $totalRuns30Days }}
-
- -
-
Active Runs
-
{{ $activeRuns }}
-
- -
-
Failed/Partial (7 days)
-
{{ $failedOrPartial7Days }}
-
- -
-
Avg Duration (7 days)
-
{{ $avgDuration7Days }}
-
-
-
diff --git a/specs/058-tenant-ui-polish/tasks.md b/specs/058-tenant-ui-polish/tasks.md index cc3fb6d..b00f851 100644 --- a/specs/058-tenant-ui-polish/tasks.md +++ b/specs/058-tenant-ui-polish/tasks.md @@ -61,22 +61,22 @@ ## Phase 4: User Story 2 — Inventory becomes a hub module (Priority: P2) ### Tests (US2) -- [x] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php -- [x] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php +- [X] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php +- [X] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php ### Implementation (US2) -- [x] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`) -- [x] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php -- [x] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php -- [x] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php -- [x] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php -- [x] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php -- [x] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php -- [x] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page) -- [x] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page) -- [x] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts) -- [x] T041 [US2] Inventory coverage semantics reference (A2) +- [X] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`) +- [X] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php +- [X] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php +- [X] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php +- [X] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php +- [X] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php +- [X] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php +- [X] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page) +- [X] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page) +- [X] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts) +- [X] T041 [US2] Inventory coverage semantics reference (A2) - Identify and document the exact source-of-truth fields for Inventory KPI aggregation: - `inventory_items.policy_type` - `config('tenantpilot.supported_policy_types')` meta fields (`restore`, `risk`) @@ -85,8 +85,8 @@ ### Implementation (US2) - DoD: - One canonical place documented and referenced by inventory KPIs. - No “magic” or duplicated classification logic across pages/widgets. -- [x] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed) -- [x] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only) +- [X] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed) +- [X] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only) **Checkpoint**: Inventory hub behaves as a module with consistent sub-navigation + header. @@ -135,6 +135,8 @@ ## Phase 6: Polish & Cross-Cutting Concerns - [X] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty` - [X] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green - [X] T040 [P] Smoke-check key pages render for a tenant in tests/Feature/Filament/AdminSmokeTest.php (add assertions only if gaps are found) +- [X] T043 Refactor KPI headers to StatsOverviewWidget (Inventory + Operations) to match Filament demo tiles and reduce custom Blade drift +- [X] T044 Inventory IA polish: remove Inventory “Overview”, make Items the default entry, rename “Inventory Sync Runs” → “Sync History”, and move “Run Inventory Sync” to Items header actions --- diff --git a/tests/Feature/Filament/InventoryHubDbOnlyTest.php b/tests/Feature/Filament/InventoryHubDbOnlyTest.php index 7e08c0c..25b460a 100644 --- a/tests/Feature/Filament/InventoryHubDbOnlyTest.php +++ b/tests/Feature/Filament/InventoryHubDbOnlyTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use App\Filament\Pages\InventoryCoverage; -use App\Filament\Pages\InventoryLanding; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventorySyncRunResource; use App\Models\InventoryItem; @@ -32,12 +31,9 @@ Bus::fake(); assertNoOutboundHttp(function () use ($tenant): void { - $this->get(InventoryLanding::getUrl(tenant: $tenant)) - ->assertOk() - ->assertSee('Run Inventory Sync'); - $this->get(InventoryItemResource::getUrl('index', tenant: $tenant)) ->assertOk() + ->assertSee('Run Inventory Sync') ->assertSee('Item A'); $this->get(InventorySyncRunResource::getUrl('index', tenant: $tenant)) diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php index 687d3cf..eaf2688 100644 --- a/tests/Feature/Filament/InventoryPagesTest.php +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -1,7 +1,6 @@ create(); $user = User::factory()->create(); @@ -19,11 +18,6 @@ $tenant->getKey() => ['role' => 'owner'], ]); - $this->actingAs($user) - ->get(InventoryLanding::getUrl(tenant: $tenant)) - ->assertOk() - ->assertSee('Run Inventory Sync'); - InventoryItem::factory()->create([ 'tenant_id' => $tenant->getKey(), 'display_name' => 'Item A', @@ -38,7 +32,6 @@ 'status' => InventorySyncRun::STATUS_SUCCESS, ]); - $landingUrl = InventoryLanding::getUrl(tenant: $tenant); $itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant); $syncRunsUrl = InventorySyncRunResource::getUrl('index', tenant: $tenant); $coverageUrl = InventoryCoverage::getUrl(tenant: $tenant); @@ -51,18 +44,10 @@ 'Inventory ops', ]; - $this->actingAs($user) - ->get($landingUrl) - ->assertOk() - ->assertSee($itemsUrl) - ->assertSee($syncRunsUrl) - ->assertSee($coverageUrl) - ->assertSee($kpiLabels); - $this->actingAs($user) ->get($itemsUrl) ->assertOk() - ->assertSee($landingUrl) + ->assertSee('Run Inventory Sync') ->assertSee($syncRunsUrl) ->assertSee($coverageUrl) ->assertSee($kpiLabels) @@ -71,7 +56,6 @@ $this->actingAs($user) ->get($syncRunsUrl) ->assertOk() - ->assertSee($landingUrl) ->assertSee($itemsUrl) ->assertSee($coverageUrl) ->assertSee($kpiLabels) @@ -80,7 +64,6 @@ $this->actingAs($user) ->get(InventoryCoverage::getUrl(tenant: $tenant)) ->assertOk() - ->assertSee($landingUrl) ->assertSee($itemsUrl) ->assertSee($syncRunsUrl) ->assertSee($kpiLabels) diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index ddeb0e8..b4e3171 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -1,6 +1,6 @@ defaultSelectionPayload()['policy_types']; - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['policy_types' => $allTypes]) ->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey()); @@ -58,7 +58,7 @@ $allTypes = $sync->defaultSelectionPayload()['policy_types']; $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->mountAction('run_inventory_sync') ->set('mountedActions.0.data.policy_types', $selectedTypes) ->assertActionDataSet(['policy_types' => $selectedTypes]) @@ -85,7 +85,7 @@ $allTypes = $sync->defaultSelectionPayload()['policy_types']; $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: [ 'policy_types' => $selectedTypes, 'include_dependencies' => false, @@ -110,7 +110,7 @@ $allTypes = $sync->defaultSelectionPayload()['policy_types']; $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->mountAction('run_inventory_sync') ->set('mountedActions.0.data.policy_types', $selectedTypes) ->assertActionDataSet(['include_foundations' => true]) @@ -135,7 +135,7 @@ $allTypes = $sync->defaultSelectionPayload()['policy_types']; $selectedTypes = array_slice($allTypes, 0, min(2, count($allTypes))); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: [ 'policy_types' => $selectedTypes, 'include_foundations' => false, @@ -159,7 +159,7 @@ $sync = app(InventorySyncService::class); $allTypes = $sync->defaultSelectionPayload()['policy_types']; - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) ->assertStatus(403); @@ -196,7 +196,7 @@ 'errors_count' => 0, ]); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]); Queue::assertNothingPushed(); @@ -211,7 +211,7 @@ $this->actingAs($user); Filament::setTenant($tenant, true); - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->assertActionHidden('run_inventory_sync'); Queue::assertNothingPushed(); diff --git a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php index 681266a..f1e5d26 100644 --- a/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php +++ b/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php @@ -1,6 +1,6 @@ defaultSelectionPayload()['policy_types'] ?? []; - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]); $opRun = OperationRun::query() diff --git a/tests/Feature/RunStartAuthorizationTest.php b/tests/Feature/RunStartAuthorizationTest.php index cb1b577..83fbb70 100644 --- a/tests/Feature/RunStartAuthorizationTest.php +++ b/tests/Feature/RunStartAuthorizationTest.php @@ -1,6 +1,6 @@ defaultSelectionPayload()['policy_types']; - Livewire::test(InventoryLanding::class) + Livewire::test(ListInventoryItems::class) ->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes]) ->assertStatus(403); -- 2.45.2