From abda7512964590c5310289437cd10221b263dcaa Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 21 Jan 2026 14:00:42 +0000 Subject: [PATCH] feat(058): tenant dashboard + active-runs gating (#68) Adds a tenant-scoped dashboard page (KPIs, Needs Attention, Recent Drift Findings, Recent Operations) with polling only while active runs exist. Guardrails: DB-only render (no outbound HTTP) + tenant isolation. Tests: ActiveRunsTest, TenantDashboardDbOnlyTest, TenantDashboardTenantScopeTest. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/68 --- 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 ++++++ scripts/mcp_gitea_smoke.py | 85 +++++++++++++ scripts/run-gitea-mcp.py | 87 +++++++++++++ .../Filament/TenantDashboardDbOnlyTest.php | 41 ++++++ .../TenantDashboardTenantScopeTest.php | 33 +++++ tests/Feature/OpsUx/ActiveRunsTest.php | 64 ++++++++++ 16 files changed, 786 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 scripts/mcp_gitea_smoke.py create mode 100644 scripts/run-gitea-mcp.py 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/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()) 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(); +});