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;
|
||||
|
||||
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([
|
||||
|
||||
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