feat(058): tenant dashboard + active-runs gating #68
34
app/Filament/Pages/TenantDashboard.php
Normal file
34
app/Filament/Pages/TenantDashboard.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||||
|
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||||
|
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||||
|
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||||
|
use Filament\Pages\Dashboard;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
use Filament\Widgets\WidgetConfiguration;
|
||||||
|
|
||||||
|
class TenantDashboard extends Dashboard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||||
|
*/
|
||||||
|
public function getWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DashboardKpis::class,
|
||||||
|
NeedsAttention::class,
|
||||||
|
RecentDriftFindings::class,
|
||||||
|
RecentOperations::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColumns(): int|array
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Filament/Widgets/Dashboard/DashboardKpis.php
Normal file
73
app/Filament/Widgets/Dashboard/DashboardKpis.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
|
class DashboardKpis extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.dashboard.dashboard-kpis';
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'pollingInterval' => null,
|
||||||
|
'openDriftFindings' => 0,
|
||||||
|
'highSeverityDriftFindings' => 0,
|
||||||
|
'activeRuns' => 0,
|
||||||
|
'inventoryActiveRuns' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
$openDriftFindings = (int) Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('status', Finding::STATUS_NEW)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$highSeverityDriftFindings = (int) Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('status', Finding::STATUS_NEW)
|
||||||
|
->where('severity', Finding::SEVERITY_HIGH)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$activeRuns = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->active()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$inventoryActiveRuns = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('type', 'inventory.sync')
|
||||||
|
->active()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||||
|
'openDriftFindings' => $openDriftFindings,
|
||||||
|
'highSeverityDriftFindings' => $highSeverityDriftFindings,
|
||||||
|
'activeRuns' => $activeRuns,
|
||||||
|
'inventoryActiveRuns' => $inventoryActiveRuns,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/Filament/Widgets/Dashboard/NeedsAttention.php
Normal file
117
app/Filament/Widgets/Dashboard/NeedsAttention.php
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Filament\Pages\DriftLanding;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
|
class NeedsAttention extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.dashboard.needs-attention';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'pollingInterval' => null,
|
||||||
|
'items' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
$highSeverityCount = (int) Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('status', Finding::STATUS_NEW)
|
||||||
|
->where('severity', Finding::SEVERITY_HIGH)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($highSeverityCount > 0) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'High severity drift findings',
|
||||||
|
'body' => "{$highSeverityCount} finding(s) need review.",
|
||||||
|
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestDriftSuccess = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('type', 'drift.generate')
|
||||||
|
->where('status', 'completed')
|
||||||
|
->where('outcome', 'succeeded')
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->latest('completed_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $latestDriftSuccess) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'No drift scan yet',
|
||||||
|
'body' => 'Generate drift after you have at least two successful inventory runs.',
|
||||||
|
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
||||||
|
|
||||||
|
if ($isStale) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Drift stale',
|
||||||
|
'body' => 'Last drift scan is older than 7 days.',
|
||||||
|
'url' => DriftLanding::getUrl(tenant: $tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestDriftFailure = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('type', 'drift.generate')
|
||||||
|
->where('status', 'completed')
|
||||||
|
->where('outcome', 'failed')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($latestDriftFailure instanceof OperationRun) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Drift generation failed',
|
||||||
|
'body' => 'Investigate the latest failed run.',
|
||||||
|
'url' => OperationRunLinks::view($latestDriftFailure, $tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeRuns = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->active()
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($activeRuns > 0) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Operations in progress',
|
||||||
|
'body' => "{$activeRuns} run(s) are active.",
|
||||||
|
'url' => OperationRunLinks::index($tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Filament/Widgets/Dashboard/RecentDriftFindings.php
Normal file
49
app/Filament/Widgets/Dashboard/RecentDriftFindings.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class RecentDriftFindings extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.dashboard.recent-drift-findings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'pollingInterval' => null,
|
||||||
|
'findings' => collect(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
/** @var Collection<int, Finding> $findings */
|
||||||
|
$findings = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->latest('created_at')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||||
|
'findings' => $findings,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Filament/Widgets/Dashboard/RecentOperations.php
Normal file
57
app/Filament/Widgets/Dashboard/RecentOperations.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\Widget;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class RecentOperations extends Widget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected string $view = 'filament.widgets.dashboard.recent-operations';
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function getViewData(): array
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'pollingInterval' => null,
|
||||||
|
'runs' => collect(),
|
||||||
|
'viewRunBaseUrl' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
/** @var Collection<int, OperationRun> $runs */
|
||||||
|
$runs = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->latest('created_at')
|
||||||
|
->limit(10)
|
||||||
|
->get()
|
||||||
|
->each(function (OperationRun $run) use ($tenant): void {
|
||||||
|
$run->setAttribute('type_label', OperationCatalog::label((string) $run->type));
|
||||||
|
$run->setAttribute('view_url', OperationRunLinks::view($run, $tenant));
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
|
||||||
|
'runs' => $runs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,12 +3,12 @@
|
|||||||
namespace App\Providers\Filament;
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
use Filament\Pages\Dashboard;
|
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\Support\Colors\Color;
|
||||||
@ -51,7 +51,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
Dashboard::class,
|
TenantDashboard::class,
|
||||||
])
|
])
|
||||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||||
->widgets([
|
->widgets([
|
||||||
|
|||||||
19
app/Support/OpsUx/ActiveRuns.php
Normal file
19
app/Support/OpsUx/ActiveRuns.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
|
||||||
|
final class ActiveRuns
|
||||||
|
{
|
||||||
|
public static function existForTenant(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->active()
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<div
|
||||||
|
@if ($pollingInterval)
|
||||||
|
wire:poll.{{ $pollingInterval }}
|
||||||
|
@endif
|
||||||
|
class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Open drift findings</div>
|
||||||
|
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $openDriftFindings }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">High severity drift</div>
|
||||||
|
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $highSeverityDriftFindings }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Active operations</div>
|
||||||
|
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $activeRuns }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Inventory active</div>
|
||||||
|
<div class="text-2xl font-semibold text-gray-950 dark:text-white">{{ $inventoryActiveRuns }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<div
|
||||||
|
@if ($pollingInterval)
|
||||||
|
wire:poll.{{ $pollingInterval }}
|
||||||
|
@endif
|
||||||
|
class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-base font-semibold text-gray-950 dark:text-white">Needs Attention</div>
|
||||||
|
|
||||||
|
@if (count($items) === 0)
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">Nothing urgent right now.</div>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
@foreach ($items as $item)
|
||||||
|
<a
|
||||||
|
href="{{ $item['url'] }}"
|
||||||
|
class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-left transition hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<div
|
||||||
|
@if ($pollingInterval)
|
||||||
|
wire:poll.{{ $pollingInterval }}
|
||||||
|
@endif
|
||||||
|
class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-base font-semibold text-gray-950 dark:text-white">Recent Drift Findings</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
@if ($findings->isEmpty())
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">No drift findings yet.</div>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach ($findings as $finding)
|
||||||
|
<div class="rounded-lg border border-gray-200 p-3 dark:border-white/10">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
{{ $finding->subject_type }} · {{ $finding->subject_external_id }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $finding->created_at?->diffForHumans() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Severity: {{ $finding->severity }} · Status: {{ $finding->status }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
<div
|
||||||
|
@if ($pollingInterval)
|
||||||
|
wire:poll.{{ $pollingInterval }}
|
||||||
|
@endif
|
||||||
|
class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-base font-semibold text-gray-950 dark:text-white">Recent Operations</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
@if ($runs->isEmpty())
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">No operations yet.</div>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
@foreach ($runs as $run)
|
||||||
|
<a
|
||||||
|
href="{{ $run->getAttribute('view_url') }}"
|
||||||
|
class="rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="text-sm font-medium text-gray-950 dark:text-white">
|
||||||
|
{{ $run->getAttribute('type_label') ?? $run->type }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $run->created_at?->diffForHumans() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Status: {{ $run->status }} · Outcome: {{ $run->outcome }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
85
scripts/mcp_gitea_smoke.py
Normal file
85
scripts/mcp_gitea_smoke.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def send(proc: subprocess.Popen[bytes], payload: dict) -> None:
|
||||||
|
proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
|
||||||
|
proc.stdin.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def read_line(proc: subprocess.Popen[bytes], timeout: float = 10.0) -> str:
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if line:
|
||||||
|
return line.decode("utf-8", errors="replace").strip()
|
||||||
|
time.sleep(0.05)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["python3", "scripts/run-gitea-mcp.py"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
send(
|
||||||
|
proc,
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "smoke", "version": "0.0.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
init_resp = read_line(proc, timeout=15.0)
|
||||||
|
if not init_resp:
|
||||||
|
err = proc.stderr.read(4096).decode("utf-8", errors="replace")
|
||||||
|
sys.stderr.write("No initialize response. stderr:\n" + err + "\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("initialize:", init_resp[:400])
|
||||||
|
|
||||||
|
send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
|
||||||
|
tools_resp = read_line(proc, timeout=15.0)
|
||||||
|
if not tools_resp:
|
||||||
|
err = proc.stderr.read(4096).decode("utf-8", errors="replace")
|
||||||
|
sys.stderr.write("No tools/list response. stderr:\n" + err + "\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("tools/list:", tools_resp[:400])
|
||||||
|
|
||||||
|
send(
|
||||||
|
proc,
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": "get_my_user_info", "arguments": {}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
me_resp = read_line(proc, timeout=20.0)
|
||||||
|
if not me_resp:
|
||||||
|
err = proc.stderr.read(4096).decode("utf-8", errors="replace")
|
||||||
|
sys.stderr.write("No get_my_user_info response. stderr:\n" + err + "\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("get_my_user_info:", me_resp[:500])
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
87
scripts/run-gitea-mcp.py
Normal file
87
scripts/run-gitea-mcp.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv(path: Path) -> dict[str, str]:
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return env
|
||||||
|
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
if value and value[0] == "'" and value.endswith("'"):
|
||||||
|
value = value[1:-1]
|
||||||
|
elif value and value[0] == '"' and value.endswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
|
||||||
|
env[key] = value
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
dotenv_path = repo_root / ".env"
|
||||||
|
|
||||||
|
dotenv = load_dotenv(dotenv_path)
|
||||||
|
|
||||||
|
required = ["GITEA_HOST", "GITEA_ACCESS_TOKEN"]
|
||||||
|
missing = [k for k in required if not dotenv.get(k)]
|
||||||
|
if missing:
|
||||||
|
sys.stderr.write(
|
||||||
|
"Missing required env vars in .env: " + ", ".join(missing) + "\n"
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
|
||||||
|
# Prefer values from .env to keep VS Code config simple.
|
||||||
|
for key in [
|
||||||
|
"GITEA_HOST",
|
||||||
|
"GITEA_ACCESS_TOKEN",
|
||||||
|
"GITEA_DEBUG",
|
||||||
|
"GITEA_READONLY",
|
||||||
|
"GITEA_INSECURE",
|
||||||
|
]:
|
||||||
|
if key in dotenv:
|
||||||
|
env[key] = dotenv[key]
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"GITEA_HOST",
|
||||||
|
"-e",
|
||||||
|
"GITEA_ACCESS_TOKEN",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Optional flags.
|
||||||
|
for optional in ["GITEA_DEBUG", "GITEA_READONLY", "GITEA_INSECURE"]:
|
||||||
|
if env.get(optional):
|
||||||
|
cmd.extend(["-e", optional])
|
||||||
|
|
||||||
|
cmd.append("docker.gitea.com/gitea-mcp-server:latest")
|
||||||
|
|
||||||
|
# Inherit stdin/stdout/stderr for MCP stdio.
|
||||||
|
completed = subprocess.run(cmd, env=env, check=False)
|
||||||
|
return int(completed.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
41
tests/Feature/Filament/TenantDashboardDbOnlyTest.php
Normal file
41
tests/Feature/Filament/TenantDashboardDbOnlyTest.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
|
||||||
|
it('renders the tenant dashboard DB-only (no outbound HTTP, no background work)', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($tenant): void {
|
||||||
|
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Needs Attention')
|
||||||
|
->assertSee('Recent Operations')
|
||||||
|
->assertSee('Recent Drift Findings');
|
||||||
|
});
|
||||||
|
|
||||||
|
Bus::assertNothingDispatched();
|
||||||
|
});
|
||||||
33
tests/Feature/Filament/TenantDashboardTenantScopeTest.php
Normal file
33
tests/Feature/Filament/TenantDashboardTenantScopeTest.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
|
||||||
|
it('does not leak data across tenants on the dashboard', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'tenant_id' => $otherTenant->getKey(),
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'subject_external_id' => 'other-tenant-finding',
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $otherTenant->getKey(),
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'status' => 'running',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('other-tenant-finding');
|
||||||
|
});
|
||||||
64
tests/Feature/OpsUx/ActiveRunsTest.php
Normal file
64
tests/Feature/OpsUx/ActiveRunsTest.php
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
|
||||||
|
it('returns false when tenant has no active runs', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ActiveRuns::existForTenant($tenant))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when tenant has queued runs', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ActiveRuns::existForTenant($tenant))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when tenant has running runs', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'status' => 'running',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ActiveRuns::existForTenant($tenant))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is tenant scoped (other tenant active runs do not count)', function (): void {
|
||||||
|
$tenantA = Tenant::factory()->create();
|
||||||
|
$tenantB = Tenant::factory()->create();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenantB->getKey(),
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'status' => 'running',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ActiveRuns::existForTenant($tenantA))->toBeFalse();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user