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 <ahmeddarrazi@adsmac.local>
Reviewed-on: #68
This commit is contained in:
ahmido 2026-01-21 14:00:42 +00:00
parent 5745461654
commit abda751296
16 changed files with 786 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
import json
import subprocess
import sys
import time
def send(proc: subprocess.Popen[bytes], payload: dict) -> None:
proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
proc.stdin.flush()
def read_line(proc: subprocess.Popen[bytes], timeout: float = 10.0) -> str:
start = time.time()
while time.time() - start < timeout:
line = proc.stdout.readline()
if line:
return line.decode("utf-8", errors="replace").strip()
time.sleep(0.05)
return ""
def main() -> int:
proc = subprocess.Popen(
["python3", "scripts/run-gitea-mcp.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
send(
proc,
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "smoke", "version": "0.0.0"},
},
},
)
init_resp = read_line(proc, timeout=15.0)
if not init_resp:
err = proc.stderr.read(4096).decode("utf-8", errors="replace")
sys.stderr.write("No initialize response. stderr:\n" + err + "\n")
return 1
print("initialize:", init_resp[:400])
send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
tools_resp = read_line(proc, timeout=15.0)
if not tools_resp:
err = proc.stderr.read(4096).decode("utf-8", errors="replace")
sys.stderr.write("No tools/list response. stderr:\n" + err + "\n")
return 1
print("tools/list:", tools_resp[:400])
send(
proc,
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {"name": "get_my_user_info", "arguments": {}},
},
)
me_resp = read_line(proc, timeout=20.0)
if not me_resp:
err = proc.stderr.read(4096).decode("utf-8", errors="replace")
sys.stderr.write("No get_my_user_info response. stderr:\n" + err + "\n")
return 1
print("get_my_user_info:", me_resp[:500])
return 0
finally:
proc.terminate()
if __name__ == "__main__":
raise SystemExit(main())

87
scripts/run-gitea-mcp.py Normal file
View File

@ -0,0 +1,87 @@
#!/usr/bin/env python3
import os
import subprocess
import sys
from pathlib import Path
def load_dotenv(path: Path) -> dict[str, str]:
env: dict[str, str] = {}
if not path.exists():
return env
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if value and value[0] == "'" and value.endswith("'"):
value = value[1:-1]
elif value and value[0] == '"' and value.endswith('"'):
value = value[1:-1]
env[key] = value
return env
def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
dotenv_path = repo_root / ".env"
dotenv = load_dotenv(dotenv_path)
required = ["GITEA_HOST", "GITEA_ACCESS_TOKEN"]
missing = [k for k in required if not dotenv.get(k)]
if missing:
sys.stderr.write(
"Missing required env vars in .env: " + ", ".join(missing) + "\n"
)
return 2
env = os.environ.copy()
# Prefer values from .env to keep VS Code config simple.
for key in [
"GITEA_HOST",
"GITEA_ACCESS_TOKEN",
"GITEA_DEBUG",
"GITEA_READONLY",
"GITEA_INSECURE",
]:
if key in dotenv:
env[key] = dotenv[key]
cmd = [
"docker",
"run",
"-i",
"--rm",
"-e",
"GITEA_HOST",
"-e",
"GITEA_ACCESS_TOKEN",
]
# Optional flags.
for optional in ["GITEA_DEBUG", "GITEA_READONLY", "GITEA_INSECURE"]:
if env.get(optional):
cmd.extend(["-e", optional])
cmd.append("docker.gitea.com/gitea-mcp-server:latest")
# Inherit stdin/stdout/stderr for MCP stdio.
completed = subprocess.run(cmd, env=env, check=False)
return int(completed.returncode)
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

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

View File

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