feat(114): system console control tower (merged) (#139)
Feature branch PR for Spec 114. This branch contains the merged agent session work (see merge commit on branch). Tests - `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #139
This commit is contained in:
parent
200498fa8e
commit
0cf612826f
@ -4,16 +4,79 @@
|
||||
|
||||
namespace App\Filament\System\Pages;
|
||||
|
||||
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
|
||||
use App\Filament\System\Widgets\ControlTowerKpis;
|
||||
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
||||
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Dashboard as BaseDashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Dashboard extends BaseDashboard
|
||||
{
|
||||
public string $window = SystemConsoleWindow::LastDay;
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||
{
|
||||
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'system', $tenant, $shouldGuessMissingParameters);
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->hasCapability(PlatformCapabilities::ACCESS_SYSTEM_PANEL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::CONSOLE_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->window = SystemConsoleWindow::fromNullable((string) request()->query('window', $this->window))->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||
*/
|
||||
public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
ControlTowerHealthIndicator::class,
|
||||
ControlTowerKpis::class,
|
||||
ControlTowerTopOffenders::class,
|
||||
ControlTowerRecentFailures::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getColumns(): int|array
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function selectedWindow(): SystemConsoleWindow
|
||||
{
|
||||
return SystemConsoleWindow::fromNullable($this->window);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
@ -27,6 +90,27 @@ protected function getHeaderActions(): array
|
||||
&& $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||
|
||||
return [
|
||||
Action::make('set_window')
|
||||
->label('Time window')
|
||||
->icon('heroicon-o-clock')
|
||||
->color('gray')
|
||||
->form([
|
||||
Select::make('window')
|
||||
->label('Window')
|
||||
->options(SystemConsoleWindow::options())
|
||||
->default($this->window)
|
||||
->required(),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$window = SystemConsoleWindow::fromNullable((string) ($data['window'] ?? null));
|
||||
|
||||
$this->window = $window->value;
|
||||
|
||||
$this->redirect(static::getUrl([
|
||||
'window' => $window->value,
|
||||
]));
|
||||
}),
|
||||
|
||||
Action::make('enter_break_glass')
|
||||
->label('Enter break-glass mode')
|
||||
->color('danger')
|
||||
|
||||
107
app/Filament/System/Pages/Directory/Tenants.php
Normal file
107
app/Filament/System/Pages/Directory/Tenants.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Tenants extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Tenants';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Directory';
|
||||
|
||||
protected static ?string $slug = 'directory/tenants';
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.tenants';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->query(function (): Builder {
|
||||
return Tenant::query()
|
||||
->with('workspace')
|
||||
->withCount([
|
||||
'providerConnections',
|
||||
'providerConnections as unhealthy_connections_count' => fn (Builder $query): Builder => $query->where('health_status', 'unhealthy'),
|
||||
'permissions as missing_permissions_count' => fn (Builder $query): Builder => $query->where('status', '!=', 'granted'),
|
||||
]);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
TextColumn::make('workspace.name')
|
||||
->label('Workspace')
|
||||
->searchable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
||||
TextColumn::make('health')
|
||||
->label('Health')
|
||||
->state(fn (Tenant $record): string => $this->healthForTenant($record))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::SystemHealth))
|
||||
->color(BadgeRenderer::color(BadgeDomain::SystemHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::SystemHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::SystemHealth)),
|
||||
])
|
||||
->recordUrl(fn (Tenant $record): string => SystemDirectoryLinks::tenantDetail($record))
|
||||
->emptyStateHeading('No tenants found')
|
||||
->emptyStateDescription('Tenants will appear here as inventory is onboarded.');
|
||||
}
|
||||
|
||||
private function healthForTenant(Tenant $tenant): string
|
||||
{
|
||||
if ((string) $tenant->status === Tenant::STATUS_ARCHIVED) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if ((int) ($tenant->getAttribute('unhealthy_connections_count') ?? 0) > 0) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if ((int) ($tenant->getAttribute('missing_permissions_count') ?? 0) > 0) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
if ((string) $tenant->status === Tenant::STATUS_ONBOARDING) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
95
app/Filament/System/Pages/Directory/ViewTenant.php
Normal file
95
app/Filament/System/Pages/Directory/ViewTenant.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPermission;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ViewTenant extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'directory/tenants/{tenant}';
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.view-tenant';
|
||||
|
||||
public Tenant $tenant;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
|
||||
}
|
||||
|
||||
public function mount(Tenant $tenant): void
|
||||
{
|
||||
$tenant->load('workspace');
|
||||
|
||||
$this->tenant = $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ProviderConnection>
|
||||
*/
|
||||
public function providerConnections(): Collection
|
||||
{
|
||||
return ProviderConnection::query()
|
||||
->where('tenant_id', (int) $this->tenant->getKey())
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('provider')
|
||||
->get(['id', 'provider', 'status', 'health_status', 'is_default', 'last_health_check_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TenantPermission>
|
||||
*/
|
||||
public function tenantPermissions(): Collection
|
||||
{
|
||||
return TenantPermission::query()
|
||||
->where('tenant_id', (int) $this->tenant->getKey())
|
||||
->orderBy('permission_key')
|
||||
->limit(20)
|
||||
->get(['id', 'permission_key', 'status', 'last_checked_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array{id: int, label: string, started: string, url: string}>
|
||||
*/
|
||||
public function recentRuns(): Collection
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', (int) $this->tenant->getKey())
|
||||
->latest('id')
|
||||
->limit(8)
|
||||
->get(['id', 'type', 'created_at'])
|
||||
->map(fn (OperationRun $run): array => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'label' => OperationCatalog::label((string) $run->type),
|
||||
'started' => $run->created_at?->diffForHumans() ?? '—',
|
||||
'url' => SystemOperationRunLinks::view($run),
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminTenantUrl(): string
|
||||
{
|
||||
return SystemDirectoryLinks::adminTenant($this->tenant);
|
||||
}
|
||||
|
||||
public function runsUrl(): string
|
||||
{
|
||||
return SystemOperationRunLinks::index();
|
||||
}
|
||||
}
|
||||
82
app/Filament/System/Pages/Directory/ViewWorkspace.php
Normal file
82
app/Filament/System/Pages/Directory/ViewWorkspace.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ViewWorkspace extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'directory/workspaces/{workspace}';
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.view-workspace';
|
||||
|
||||
public Workspace $workspace;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
|
||||
}
|
||||
|
||||
public function mount(Workspace $workspace): void
|
||||
{
|
||||
$workspace->loadCount('tenants');
|
||||
|
||||
$this->workspace = $workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Tenant>
|
||||
*/
|
||||
public function workspaceTenants(): Collection
|
||||
{
|
||||
return Tenant::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->orderBy('name')
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'status', 'workspace_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array{id: int, label: string, started: string, url: string}>
|
||||
*/
|
||||
public function recentRuns(): Collection
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->latest('id')
|
||||
->limit(8)
|
||||
->get(['id', 'type', 'created_at'])
|
||||
->map(fn (OperationRun $run): array => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'label' => OperationCatalog::label((string) $run->type),
|
||||
'started' => $run->created_at?->diffForHumans() ?? '—',
|
||||
'url' => SystemOperationRunLinks::view($run),
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminWorkspaceUrl(): string
|
||||
{
|
||||
return SystemDirectoryLinks::adminWorkspace($this->workspace);
|
||||
}
|
||||
|
||||
public function runsUrl(): string
|
||||
{
|
||||
return SystemOperationRunLinks::index();
|
||||
}
|
||||
}
|
||||
116
app/Filament/System/Pages/Directory/Workspaces.php
Normal file
116
app/Filament/System/Pages/Directory/Workspaces.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Workspaces extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Workspaces';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Directory';
|
||||
|
||||
protected static ?string $slug = 'directory/workspaces';
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.workspaces';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->query(function (): Builder {
|
||||
return Workspace::query()
|
||||
->withCount([
|
||||
'tenants',
|
||||
'tenants as onboarding_tenants_count' => fn (Builder $query): Builder => $query->where('status', Tenant::STATUS_ONBOARDING),
|
||||
]);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Workspace')
|
||||
->searchable(),
|
||||
TextColumn::make('tenants_count')
|
||||
->label('Tenants'),
|
||||
TextColumn::make('health')
|
||||
->label('Health')
|
||||
->state(fn (Workspace $record): string => $this->healthForWorkspace($record))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::SystemHealth))
|
||||
->color(BadgeRenderer::color(BadgeDomain::SystemHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::SystemHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::SystemHealth)),
|
||||
TextColumn::make('failed_runs_24h')
|
||||
->label('Failed (24h)')
|
||||
->state(fn (Workspace $record): int => (int) OperationRun::query()
|
||||
->where('workspace_id', (int) $record->getKey())
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->count()),
|
||||
])
|
||||
->recordUrl(fn (Workspace $record): string => SystemDirectoryLinks::workspaceDetail($record))
|
||||
->emptyStateHeading('No workspaces found')
|
||||
->emptyStateDescription('Workspace inventory will appear here once workspaces are created.');
|
||||
}
|
||||
|
||||
private function healthForWorkspace(Workspace $workspace): string
|
||||
{
|
||||
$tenantsCount = (int) ($workspace->getAttribute('tenants_count') ?? 0);
|
||||
$onboardingTenantsCount = (int) ($workspace->getAttribute('onboarding_tenants_count') ?? 0);
|
||||
|
||||
if ($tenantsCount === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$hasRecentFailures = OperationRun::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->exists();
|
||||
|
||||
if ($hasRecentFailures) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if ($onboardingTenantsCount > 0) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
190
app/Filament/System/Pages/Ops/Failures.php
Normal file
190
app/Filament/System/Pages/Ops/Failures.php
Normal file
@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\SystemConsole\OperationRunTriageService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Failures extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Failures';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
||||
|
||||
protected static ?string $slug = 'ops/failures';
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.failures';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$count = OperationRun::query()
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->count();
|
||||
|
||||
return $count > 0 ? (string) $count : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): string|array|null
|
||||
{
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->query(function (): Builder {
|
||||
return OperationRun::query()
|
||||
->with(['tenant', 'workspace'])
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('Run')
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->searchable(),
|
||||
TextColumn::make('workspace.name')
|
||||
->label('Workspace')
|
||||
->toggleable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')->label('Started')->since(),
|
||||
])
|
||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
||||
->actions([
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$retryRun = $triageService->retry($record, $user);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(SystemOperationRunLinks::view($retryRun)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($record, $user);
|
||||
|
||||
Notification::make()
|
||||
->title('Run cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label('Mark investigated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title('Run marked as investigated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No failed runs found')
|
||||
->emptyStateDescription('Failed operations will appear here for triage.')
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
private function canManageOperations(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
||||
}
|
||||
|
||||
private function requireManageUser(): PlatformUser
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@ -6,13 +6,16 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\SystemConsole\OperationRunTriageService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
@ -42,8 +45,8 @@ public static function canAccess(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
|
||||
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
|
||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
@ -56,49 +59,114 @@ public function table(Table $table): Table
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->query(function (): Builder {
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||
|
||||
$workspaceId = $platformTenant instanceof Tenant ? (int) $platformTenant->workspace_id : null;
|
||||
|
||||
return OperationRun::query()
|
||||
->with('tenant')
|
||||
->when($workspaceId, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId))
|
||||
->when(! $workspaceId, fn (Builder $query): Builder => $query->whereRaw('1 = 0'))
|
||||
->where('type', FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY);
|
||||
->with(['tenant', 'workspace']);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('Run')
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextColumn::make('scope')
|
||||
->label('Scope')
|
||||
->getStateUsing(function (OperationRun $record): string {
|
||||
$scope = (string) data_get($record->context, 'runbook.scope', 'unknown');
|
||||
$tenantName = $record->tenant instanceof Tenant ? $record->tenant->name : null;
|
||||
|
||||
if ($scope === 'single_tenant' && $tenantName) {
|
||||
return "Single tenant ({$tenantName})";
|
||||
}
|
||||
|
||||
return $scope === 'all_tenants' ? 'All tenants' : $scope;
|
||||
}),
|
||||
TextColumn::make('initiator_name')->label('Initiator'),
|
||||
TextColumn::make('created_at')->label('Started')->since(),
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->searchable(),
|
||||
TextColumn::make('workspace.name')
|
||||
->label('Workspace')
|
||||
->toggleable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
|
||||
->toggleable(),
|
||||
TextColumn::make('initiator_name')->label('Initiator'),
|
||||
TextColumn::make('created_at')->label('Started')->since(),
|
||||
])
|
||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(fn (OperationRun $record): string => SystemOperationRunLinks::view($record)),
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$retryRun = $triageService->retry($record, $user);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(SystemOperationRunLinks::view($retryRun)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($record, $user);
|
||||
|
||||
Notification::make()
|
||||
->title('Run cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label('Mark investigated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title('Run marked as investigated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No operation runs yet')
|
||||
->emptyStateDescription('Runs from all workspaces will appear here when operations are queued.')
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
private function canManageOperations(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
||||
}
|
||||
|
||||
private function requireManageUser(): PlatformUser
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
190
app/Filament/System/Pages/Ops/Stuck.php
Normal file
190
app/Filament/System/Pages/Ops/Stuck.php
Normal file
@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\SystemConsole\OperationRunTriageService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\StuckRunClassifier;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Stuck extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Stuck';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
||||
|
||||
protected static ?string $slug = 'ops/stuck';
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.stuck';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$count = app(StuckRunClassifier::class)
|
||||
->apply(OperationRun::query())
|
||||
->count();
|
||||
|
||||
return $count > 0 ? (string) $count : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): string|array|null
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->query(function (): Builder {
|
||||
return app(StuckRunClassifier::class)->apply(
|
||||
OperationRun::query()
|
||||
->with(['tenant', 'workspace'])
|
||||
);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('Run')
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextColumn::make('stuck_class')
|
||||
->label('Stuck class')
|
||||
->state(function (OperationRun $record): string {
|
||||
$classification = app(StuckRunClassifier::class)->classify($record);
|
||||
|
||||
return $classification === OperationRunStatus::Queued->value ? 'Queued too long' : 'Running too long';
|
||||
}),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->searchable(),
|
||||
TextColumn::make('workspace.name')
|
||||
->label('Workspace')
|
||||
->toggleable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')->label('Started')->since(),
|
||||
])
|
||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
||||
->actions([
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$retryRun = $triageService->retry($record, $user);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(SystemOperationRunLinks::view($retryRun)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($record, $user);
|
||||
|
||||
Notification::make()
|
||||
->title('Run cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label('Mark investigated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title('Run marked as investigated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No stuck runs found')
|
||||
->emptyStateDescription('Queued and running runs outside thresholds will appear here.')
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
private function canManageOperations(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
||||
}
|
||||
|
||||
private function requireManageUser(): PlatformUser
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,13 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\SystemConsole\OperationRunTriageService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class ViewRun extends Page
|
||||
@ -29,26 +33,96 @@ public static function canAccess(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
|
||||
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
|
||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(OperationRun $run): void
|
||||
{
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||
|
||||
$workspaceId = $platformTenant instanceof Tenant ? (int) $platformTenant->workspace_id : null;
|
||||
|
||||
$run->load('tenant');
|
||||
|
||||
if ($workspaceId === null || (int) $run->workspace_id !== $workspaceId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((string) $run->type !== FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY) {
|
||||
abort(404);
|
||||
}
|
||||
$run->load(['tenant', 'workspace']);
|
||||
|
||||
$this->run = $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('go_to_runbooks')
|
||||
->label('Go to runbooks')
|
||||
->url(Runbooks::getUrl(panel: 'system')),
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($this->run))
|
||||
->action(function (OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$retryRun = $triageService->retry($this->run, $user);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(SystemOperationRunLinks::view($retryRun)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($this->run))
|
||||
->action(function (OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($this->run, $user);
|
||||
|
||||
Notification::make()
|
||||
->title('Run cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label('Mark investigated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (array $data, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->markInvestigated($this->run, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title('Run marked as investigated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private function canManageOperations(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
||||
}
|
||||
|
||||
private function requireManageUser(): PlatformUser
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
namespace App\Filament\System\Pages;
|
||||
|
||||
use App\Filament\System\Widgets\RepairWorkspaceOwnersStats;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -18,9 +20,18 @@
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RepairWorkspaceOwners extends Page
|
||||
class RepairWorkspaceOwners extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||
|
||||
protected static ?string $navigationLabel = 'Repair workspace owners';
|
||||
@ -40,6 +51,102 @@ public static function canAccess(): bool
|
||||
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$total = Workspace::query()->count();
|
||||
$withOwners = WorkspaceMembership::query()
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->distinct('workspace_id')
|
||||
->count('workspace_id');
|
||||
|
||||
$ownerless = $total - $withOwners;
|
||||
|
||||
return $ownerless > 0 ? (string) $ownerless : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): string|array|null
|
||||
{
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string<Widget>|WidgetConfiguration>
|
||||
*/
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
RepairWorkspaceOwnersStats::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->heading('Workspaces')
|
||||
->description('Current workspace ownership status.')
|
||||
->defaultSort('name', 'asc')
|
||||
->query(function (): Builder {
|
||||
return Workspace::query()
|
||||
->withCount([
|
||||
'memberships as owner_count' => function (Builder $query): void {
|
||||
$query->where('role', WorkspaceRole::Owner->value);
|
||||
},
|
||||
'memberships as member_count',
|
||||
'tenants as tenant_count',
|
||||
]);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Workspace')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('owner_count')
|
||||
->label('Owners')
|
||||
->badge()
|
||||
->color(fn (int $state): string => $state > 0 ? 'success' : 'danger')
|
||||
->sortable(),
|
||||
TextColumn::make('member_count')
|
||||
->label('Members')
|
||||
->sortable(),
|
||||
TextColumn::make('tenant_count')
|
||||
->label('Tenants')
|
||||
->sortable(),
|
||||
TextColumn::make('updated_at')
|
||||
->label('Last activity')
|
||||
->since()
|
||||
->sortable(),
|
||||
])
|
||||
->emptyStateHeading('No workspaces')
|
||||
->emptyStateDescription('No workspaces exist in the system yet.')
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{action: string, actor: string|null, workspace: string|null, recorded_at: string}>
|
||||
*/
|
||||
public function getRecentBreakGlassActions(): array
|
||||
{
|
||||
return AuditLog::query()
|
||||
->where('action', 'like', '%break_glass%')
|
||||
->orderByDesc('recorded_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (AuditLog $log): array => [
|
||||
'action' => (string) $log->action,
|
||||
'actor' => $log->actor_email ?: 'Unknown',
|
||||
'workspace' => $log->metadata['metadata']['workspace_id'] ?? null
|
||||
? Workspace::query()->whereKey((int) $log->metadata['metadata']['workspace_id'])->value('name')
|
||||
: null,
|
||||
'recorded_at' => $log->recorded_at?->diffForHumans() ?? 'Unknown',
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
@ -49,7 +156,8 @@ protected function getHeaderActions(): array
|
||||
|
||||
return [
|
||||
Action::make('assign_owner')
|
||||
->label('Assign owner (break-glass)')
|
||||
->label('Emergency: Assign Owner')
|
||||
->icon('heroicon-o-shield-exclamation')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Assign workspace owner')
|
||||
@ -163,7 +271,8 @@ protected function getHeaderActions(): array
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->disabled(fn (): bool => ! $breakGlass->isActive()),
|
||||
->disabled(fn (): bool => ! $breakGlass->isActive())
|
||||
->tooltip(fn (): ?string => ! $breakGlass->isActive() ? 'Activate break-glass mode on the Dashboard first.' : null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Filament/System/Pages/Security/AccessLogs.php
Normal file
73
app/Filament/System/Pages/Security/AccessLogs.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Security;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class AccessLogs extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Access logs';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Security';
|
||||
|
||||
protected static ?string $slug = 'security/access-logs';
|
||||
|
||||
protected string $view = 'filament.system.pages.security.access-logs';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::CONSOLE_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('recorded_at', 'desc')
|
||||
->query(function (): Builder {
|
||||
return AuditLog::query()
|
||||
->where(function (Builder $query): void {
|
||||
$query
|
||||
->where('action', 'platform.auth.login')
|
||||
->orWhere('action', 'like', 'platform.break_glass.%');
|
||||
});
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('recorded_at')
|
||||
->label('Recorded')
|
||||
->since(),
|
||||
TextColumn::make('action')
|
||||
->label('Action')
|
||||
->searchable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (?string $state): string => $state === 'failure' ? 'danger' : 'success'),
|
||||
TextColumn::make('actor_email')
|
||||
->label('Actor')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Unknown'),
|
||||
])
|
||||
->emptyStateHeading('No access logs found')
|
||||
->emptyStateDescription('Platform login and break-glass events will appear here.');
|
||||
}
|
||||
}
|
||||
73
app/Filament/System/Widgets/ControlTowerHealthIndicator.php
Normal file
73
app/Filament/System/Widgets/ControlTowerHealthIndicator.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\SystemConsole\StuckRunClassifier;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ControlTowerHealthIndicator extends Widget
|
||||
{
|
||||
protected string $view = 'filament.system.widgets.control-tower-health-indicator';
|
||||
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array{level: string, color: string, icon: string, label: string, failed: int, stuck: int}
|
||||
*/
|
||||
public function getHealthData(): array
|
||||
{
|
||||
$now = CarbonImmutable::now();
|
||||
$last24h = $now->subHours(24);
|
||||
|
||||
$failedRuns = OperationRun::query()
|
||||
->where('created_at', '>=', $last24h)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->count();
|
||||
|
||||
$stuckRuns = app(StuckRunClassifier::class)
|
||||
->apply(OperationRun::query())
|
||||
->count();
|
||||
|
||||
if ($failedRuns > 0 || $stuckRuns > 0) {
|
||||
$level = ($failedRuns >= 5 || $stuckRuns >= 3) ? 'critical' : 'warning';
|
||||
} else {
|
||||
$level = 'healthy';
|
||||
}
|
||||
|
||||
return match ($level) {
|
||||
'critical' => [
|
||||
'level' => 'critical',
|
||||
'color' => 'danger',
|
||||
'icon' => 'heroicon-o-x-circle',
|
||||
'label' => 'Critical',
|
||||
'failed' => $failedRuns,
|
||||
'stuck' => $stuckRuns,
|
||||
],
|
||||
'warning' => [
|
||||
'level' => 'warning',
|
||||
'color' => 'warning',
|
||||
'icon' => 'heroicon-o-exclamation-triangle',
|
||||
'label' => 'Attention needed',
|
||||
'failed' => $failedRuns,
|
||||
'stuck' => $stuckRuns,
|
||||
],
|
||||
default => [
|
||||
'level' => 'healthy',
|
||||
'color' => 'success',
|
||||
'icon' => 'heroicon-o-check-circle',
|
||||
'label' => 'All systems healthy',
|
||||
'failed' => 0,
|
||||
'stuck' => 0,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
65
app/Filament/System/Widgets/ControlTowerKpis.php
Normal file
65
app/Filament/System/Widgets/ControlTowerKpis.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\StuckRunClassifier;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class ControlTowerKpis extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
$start = $window->startAt();
|
||||
|
||||
$baseQuery = OperationRun::query()->where('created_at', '>=', $start);
|
||||
|
||||
$totalRuns = (clone $baseQuery)->count();
|
||||
|
||||
$activeRuns = (clone $baseQuery)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->count();
|
||||
|
||||
$failedRuns = (clone $baseQuery)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->count();
|
||||
|
||||
$stuckRuns = app(StuckRunClassifier::class)
|
||||
->apply((clone $baseQuery))
|
||||
->count();
|
||||
|
||||
return [
|
||||
Stat::make('Runs in window', $totalRuns)
|
||||
->description($window::options()[$window->value] ?? 'Last 24 hours')
|
||||
->url(SystemOperationRunLinks::index()),
|
||||
Stat::make('Active', $activeRuns)
|
||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
||||
->url(SystemOperationRunLinks::index()),
|
||||
Stat::make('Failed', $failedRuns)
|
||||
->color($failedRuns > 0 ? 'danger' : 'gray')
|
||||
->url(SystemOperationRunLinks::index()),
|
||||
Stat::make('Stuck', $stuckRuns)
|
||||
->color($stuckRuns > 0 ? 'danger' : 'gray')
|
||||
->url(SystemOperationRunLinks::index()),
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Filament/System/Widgets/ControlTowerRecentFailures.php
Normal file
61
app/Filament/System/Widgets/ControlTowerRecentFailures.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ControlTowerRecentFailures extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected string $view = 'filament.system.widgets.control-tower-recent-failures';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
$start = $window->startAt();
|
||||
|
||||
/** @var Collection<int, OperationRun> $runs */
|
||||
$runs = OperationRun::query()
|
||||
->with('tenant')
|
||||
->where('created_at', '>=', $start)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->latest('id')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'windowLabel' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
|
||||
'runs' => $runs->map(function (OperationRun $run): array {
|
||||
$failureSummary = is_array($run->failure_summary) ? $run->failure_summary : [];
|
||||
$primaryFailure = is_array($failureSummary[0] ?? null) ? $failureSummary[0] : [];
|
||||
$failureMessage = trim((string) ($primaryFailure['message'] ?? ''));
|
||||
|
||||
return [
|
||||
'id' => (int) $run->getKey(),
|
||||
'operation' => OperationCatalog::label((string) $run->type),
|
||||
'tenant' => $run->tenant?->name ?? 'Tenantless',
|
||||
'created_at' => $run->created_at?->diffForHumans() ?? '—',
|
||||
'failure_message' => $failureMessage !== '' ? $failureMessage : 'No failure details available',
|
||||
'url' => SystemOperationRunLinks::view($run),
|
||||
];
|
||||
}),
|
||||
'runsUrl' => SystemOperationRunLinks::index(),
|
||||
];
|
||||
}
|
||||
}
|
||||
91
app/Filament/System/Widgets/ControlTowerTopOffenders.php
Normal file
91
app/Filament/System/Widgets/ControlTowerTopOffenders.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ControlTowerTopOffenders extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected string $view = 'filament.system.widgets.control-tower-top-offenders';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
$start = $window->startAt();
|
||||
|
||||
/** @var Collection<int, OperationRun> $grouped */
|
||||
$grouped = OperationRun::query()
|
||||
->selectRaw('workspace_id, tenant_id, type, COUNT(*) AS failed_count')
|
||||
->where('created_at', '>=', $start)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->groupBy('workspace_id', 'tenant_id', 'type')
|
||||
->orderByDesc('failed_count')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$workspaceIds = $grouped
|
||||
->pluck('workspace_id')
|
||||
->filter(fn ($value): bool => is_numeric($value))
|
||||
->map(fn ($value): int => (int) $value)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$tenantIds = $grouped
|
||||
->pluck('tenant_id')
|
||||
->filter(fn ($value): bool => is_numeric($value))
|
||||
->map(fn ($value): int => (int) $value)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$workspaceNames = Workspace::query()
|
||||
->whereIn('id', $workspaceIds)
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
|
||||
$tenantNames = Tenant::query()
|
||||
->whereIn('id', $tenantIds)
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
|
||||
return [
|
||||
'windowLabel' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
|
||||
'offenders' => $grouped->map(function (OperationRun $record) use ($workspaceNames, $tenantNames): array {
|
||||
$workspaceId = is_numeric($record->workspace_id) ? (int) $record->workspace_id : null;
|
||||
$tenantId = is_numeric($record->tenant_id) ? (int) $record->tenant_id : null;
|
||||
|
||||
return [
|
||||
'workspace_label' => $workspaceId !== null
|
||||
? ($workspaceNames[$workspaceId] ?? ('Workspace #'.$workspaceId))
|
||||
: 'Unknown workspace',
|
||||
'tenant_label' => $tenantId !== null
|
||||
? ($tenantNames[$tenantId] ?? ('Tenant #'.$tenantId))
|
||||
: 'Tenantless',
|
||||
'operation_label' => OperationCatalog::label((string) $record->type),
|
||||
'failed_count' => (int) $record->getAttribute('failed_count'),
|
||||
];
|
||||
}),
|
||||
'runsUrl' => SystemOperationRunLinks::index(),
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Filament/System/Widgets/RepairWorkspaceOwnersStats.php
Normal file
50
app/Filament/System/Widgets/RepairWorkspaceOwnersStats.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class RepairWorkspaceOwnersStats extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$totalWorkspaces = Workspace::query()->count();
|
||||
|
||||
$workspacesWithOwners = WorkspaceMembership::query()
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->distinct('workspace_id')
|
||||
->count('workspace_id');
|
||||
|
||||
$ownerlessWorkspaces = $totalWorkspaces - $workspacesWithOwners;
|
||||
|
||||
$totalMembers = WorkspaceMembership::query()->count();
|
||||
|
||||
return [
|
||||
Stat::make('Total workspaces', $totalWorkspaces)
|
||||
->color('gray')
|
||||
->icon('heroicon-o-rectangle-stack'),
|
||||
Stat::make('Healthy (has owner)', $workspacesWithOwners)
|
||||
->color($workspacesWithOwners > 0 ? 'success' : 'gray')
|
||||
->icon('heroicon-o-check-circle'),
|
||||
Stat::make('Ownerless', $ownerlessWorkspaces)
|
||||
->color($ownerlessWorkspaces > 0 ? 'danger' : 'success')
|
||||
->icon($ownerlessWorkspaces > 0 ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-check-circle'),
|
||||
Stat::make('Total memberships', $totalMembers)
|
||||
->color('gray')
|
||||
->icon('heroicon-o-users'),
|
||||
];
|
||||
}
|
||||
}
|
||||
202
app/Services/SystemConsole/OperationRunTriageService.php
Normal file
202
app/Services/SystemConsole/OperationRunTriageService.php
Normal file
@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\SystemConsole;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class OperationRunTriageService
|
||||
{
|
||||
private const RETRYABLE_TYPES = [
|
||||
'inventory_sync',
|
||||
'policy.sync',
|
||||
'policy.sync_one',
|
||||
'entra_group_sync',
|
||||
'drift_generate_findings',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
];
|
||||
|
||||
private const CANCELABLE_TYPES = [
|
||||
'inventory_sync',
|
||||
'policy.sync',
|
||||
'policy.sync_one',
|
||||
'entra_group_sync',
|
||||
'drift_generate_findings',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly OperationRunService $operationRunService,
|
||||
private readonly SystemConsoleAuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function canRetry(OperationRun $run): bool
|
||||
{
|
||||
return (string) $run->status === OperationRunStatus::Completed->value
|
||||
&& (string) $run->outcome === OperationRunOutcome::Failed->value
|
||||
&& in_array((string) $run->type, self::RETRYABLE_TYPES, true);
|
||||
}
|
||||
|
||||
public function canCancel(OperationRun $run): bool
|
||||
{
|
||||
return in_array((string) $run->status, [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
], true)
|
||||
&& in_array((string) $run->type, self::CANCELABLE_TYPES, true);
|
||||
}
|
||||
|
||||
public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
{
|
||||
if (! $this->canRetry($run)) {
|
||||
throw new InvalidArgumentException('Operation run is not retryable.');
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['triage'] = array_merge(
|
||||
is_array($context['triage'] ?? null) ? $context['triage'] : [],
|
||||
[
|
||||
'retry_of_run_id' => (int) $run->getKey(),
|
||||
'retried_at' => now()->toISOString(),
|
||||
'retried_by' => [
|
||||
'platform_user_id' => (int) $actor->getKey(),
|
||||
'name' => $actor->name,
|
||||
'email' => $actor->email,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$retryRun = OperationRun::query()->create([
|
||||
'workspace_id' => (int) $run->workspace_id,
|
||||
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||
'user_id' => null,
|
||||
'initiator_name' => $actor->name ?? 'Platform operator',
|
||||
'type' => (string) $run->type,
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => hash('sha256', 'retry|'.$run->getKey().'|'.now()->format('U.u').'|'.bin2hex(random_bytes(8))),
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
'context' => $context,
|
||||
'started_at' => null,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
$this->auditLogger->log(
|
||||
actor: $actor,
|
||||
action: 'platform.system_console.retry',
|
||||
metadata: [
|
||||
'source_run_id' => (int) $run->getKey(),
|
||||
'new_run_id' => (int) $retryRun->getKey(),
|
||||
'operation_type' => (string) $run->type,
|
||||
],
|
||||
run: $retryRun,
|
||||
);
|
||||
|
||||
return $retryRun;
|
||||
}
|
||||
|
||||
public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
{
|
||||
if (! $this->canCancel($run)) {
|
||||
throw new InvalidArgumentException('Operation run is not cancelable.');
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['triage'] = array_merge(
|
||||
is_array($context['triage'] ?? null) ? $context['triage'] : [],
|
||||
[
|
||||
'cancelled_at' => now()->toISOString(),
|
||||
'cancelled_by' => [
|
||||
'platform_user_id' => (int) $actor->getKey(),
|
||||
'name' => $actor->name,
|
||||
'email' => $actor->email,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$run->update([
|
||||
'context' => $context,
|
||||
]);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$cancelledRun = $this->operationRunService->updateRun(
|
||||
$run,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'run.cancelled',
|
||||
'message' => 'Run cancelled by platform operator triage action.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->auditLogger->log(
|
||||
actor: $actor,
|
||||
action: 'platform.system_console.cancel',
|
||||
metadata: [
|
||||
'operation_type' => (string) $run->type,
|
||||
],
|
||||
run: $cancelledRun,
|
||||
);
|
||||
|
||||
return $cancelledRun;
|
||||
}
|
||||
|
||||
public function markInvestigated(OperationRun $run, PlatformUser $actor, string $reason): OperationRun
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
if (mb_strlen($reason) < 5 || mb_strlen($reason) > 500) {
|
||||
throw new InvalidArgumentException('Investigation reason must be between 5 and 500 characters.');
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['triage'] = array_merge(
|
||||
is_array($context['triage'] ?? null) ? $context['triage'] : [],
|
||||
[
|
||||
'investigated' => [
|
||||
'reason' => $reason,
|
||||
'investigated_at' => now()->toISOString(),
|
||||
'investigated_by' => [
|
||||
'platform_user_id' => (int) $actor->getKey(),
|
||||
'name' => $actor->name,
|
||||
'email' => $actor->email,
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$run->update([
|
||||
'context' => $context,
|
||||
]);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$this->auditLogger->log(
|
||||
actor: $actor,
|
||||
action: 'platform.system_console.mark_investigated',
|
||||
metadata: [
|
||||
'reason' => $reason,
|
||||
'operation_type' => (string) $run->type,
|
||||
],
|
||||
run: $run,
|
||||
);
|
||||
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
54
app/Services/SystemConsole/SystemConsoleAuditLogger.php
Normal file
54
app/Services/SystemConsole/SystemConsoleAuditLogger.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\SystemConsole;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
|
||||
final class SystemConsoleAuditLogger
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly BreakGlassSession $breakGlassSession,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public function log(
|
||||
PlatformUser $actor,
|
||||
string $action,
|
||||
string $status = 'success',
|
||||
array $metadata = [],
|
||||
?OperationRun $run = null,
|
||||
): void {
|
||||
$tenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata['break_glass_active'] = $this->breakGlassSession->isActive();
|
||||
|
||||
if ($run instanceof OperationRun) {
|
||||
$metadata['operation_run_id'] = (int) $run->getKey();
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: trim($action),
|
||||
context: ['metadata' => $metadata],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: trim($status),
|
||||
resourceType: $run instanceof OperationRun ? 'operation_run' : null,
|
||||
resourceId: $run instanceof OperationRun ? (string) $run->getKey() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,14 @@ class PlatformCapabilities
|
||||
|
||||
public const USE_BREAK_GLASS = 'platform.use_break_glass';
|
||||
|
||||
public const CONSOLE_VIEW = 'platform.console.view';
|
||||
|
||||
public const DIRECTORY_VIEW = 'platform.directory.view';
|
||||
|
||||
public const OPERATIONS_VIEW = 'platform.operations.view';
|
||||
|
||||
public const OPERATIONS_MANAGE = 'platform.operations.manage';
|
||||
|
||||
public const OPS_VIEW = 'platform.ops.view';
|
||||
|
||||
public const RUNBOOKS_VIEW = 'platform.runbooks.view';
|
||||
|
||||
@ -42,6 +42,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
||||
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
||||
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
||||
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -34,4 +34,5 @@ enum BadgeDomain: string
|
||||
case BaselineProfileStatus = 'baseline_profile_status';
|
||||
case FindingType = 'finding_type';
|
||||
case ReviewPackStatus = 'review_pack_status';
|
||||
case SystemHealth = 'system_health';
|
||||
}
|
||||
|
||||
25
app/Support/Badges/Domains/SystemHealthBadge.php
Normal file
25
app/Support/Badges/Domains/SystemHealthBadge.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class SystemHealthBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
|
||||
'warn' => new BadgeSpec('Warn', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'critical' => new BadgeSpec('Critical', 'danger', 'heroicon-m-x-circle'),
|
||||
'unknown' => BadgeSpec::unknown(),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
53
app/Support/System/SystemDirectoryLinks.php
Normal file
53
app/Support/System/SystemDirectoryLinks.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\System;
|
||||
|
||||
use App\Filament\System\Pages\Directory\Tenants;
|
||||
use App\Filament\System\Pages\Directory\ViewTenant;
|
||||
use App\Filament\System\Pages\Directory\ViewWorkspace;
|
||||
use App\Filament\System\Pages\Directory\Workspaces;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
|
||||
final class SystemDirectoryLinks
|
||||
{
|
||||
public static function workspacesIndex(): string
|
||||
{
|
||||
return Workspaces::getUrl(panel: 'system');
|
||||
}
|
||||
|
||||
public static function workspaceDetail(Workspace|int $workspace): string
|
||||
{
|
||||
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
|
||||
|
||||
return ViewWorkspace::getUrl(['workspace' => $workspaceId], panel: 'system');
|
||||
}
|
||||
|
||||
public static function tenantsIndex(): string
|
||||
{
|
||||
return Tenants::getUrl(panel: 'system');
|
||||
}
|
||||
|
||||
public static function tenantDetail(Tenant|int $tenant): string
|
||||
{
|
||||
$tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant;
|
||||
|
||||
return ViewTenant::getUrl(['tenant' => $tenantId], panel: 'system');
|
||||
}
|
||||
|
||||
public static function adminWorkspace(Workspace|int $workspace): string
|
||||
{
|
||||
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
|
||||
|
||||
return route('filament.admin.resources.workspaces.view', ['record' => $workspaceId]);
|
||||
}
|
||||
|
||||
public static function adminTenant(Tenant|int $tenant): string
|
||||
{
|
||||
$tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant;
|
||||
|
||||
return route('filament.admin.resources.tenants.view', ['record' => $tenantId]);
|
||||
}
|
||||
}
|
||||
86
app/Support/SystemConsole/StuckRunClassifier.php
Normal file
86
app/Support/SystemConsole/StuckRunClassifier.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\SystemConsole;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class StuckRunClassifier
|
||||
{
|
||||
public function __construct(
|
||||
private readonly int $queuedThresholdMinutes = 0,
|
||||
private readonly int $runningThresholdMinutes = 0,
|
||||
) {}
|
||||
|
||||
public function queuedThresholdMinutes(): int
|
||||
{
|
||||
if ($this->queuedThresholdMinutes > 0) {
|
||||
return $this->queuedThresholdMinutes;
|
||||
}
|
||||
|
||||
return max(1, (int) config('tenantpilot.system_console.stuck_thresholds.queued_minutes', 15));
|
||||
}
|
||||
|
||||
public function runningThresholdMinutes(): int
|
||||
{
|
||||
if ($this->runningThresholdMinutes > 0) {
|
||||
return $this->runningThresholdMinutes;
|
||||
}
|
||||
|
||||
return max(1, (int) config('tenantpilot.system_console.stuck_thresholds.running_minutes', 30));
|
||||
}
|
||||
|
||||
public function apply(Builder $query, ?CarbonImmutable $now = null): Builder
|
||||
{
|
||||
$now ??= CarbonImmutable::now();
|
||||
|
||||
$queuedCutoff = $now->subMinutes($this->queuedThresholdMinutes());
|
||||
$runningCutoff = $now->subMinutes($this->runningThresholdMinutes());
|
||||
|
||||
return $query->where(function (Builder $stuckQuery) use ($queuedCutoff, $runningCutoff): void {
|
||||
$stuckQuery
|
||||
->where(function (Builder $queuedQuery) use ($queuedCutoff): void {
|
||||
$queuedQuery
|
||||
->where('status', OperationRunStatus::Queued->value)
|
||||
->whereNull('started_at')
|
||||
->where('created_at', '<=', $queuedCutoff);
|
||||
})
|
||||
->orWhere(function (Builder $runningQuery) use ($runningCutoff): void {
|
||||
$runningQuery
|
||||
->where('status', OperationRunStatus::Running->value)
|
||||
->where('started_at', '<=', $runningCutoff);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function classify(OperationRun $run, ?CarbonImmutable $now = null): ?string
|
||||
{
|
||||
$now ??= CarbonImmutable::now();
|
||||
|
||||
if ($run->status === OperationRunStatus::Queued->value) {
|
||||
if ($run->started_at !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($run->created_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $run->created_at->lte($now->subMinutes($this->queuedThresholdMinutes()))
|
||||
? OperationRunStatus::Queued->value
|
||||
: null;
|
||||
}
|
||||
|
||||
if ($run->status === OperationRunStatus::Running->value && $run->started_at !== null) {
|
||||
return $run->started_at->lte($now->subMinutes($this->runningThresholdMinutes()))
|
||||
? OperationRunStatus::Running->value
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
57
app/Support/SystemConsole/SystemConsoleWindow.php
Normal file
57
app/Support/SystemConsole/SystemConsoleWindow.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\SystemConsole;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class SystemConsoleWindow
|
||||
{
|
||||
public const LastHour = '1h';
|
||||
|
||||
public const LastDay = '24h';
|
||||
|
||||
public const LastWeek = '7d';
|
||||
|
||||
private function __construct(
|
||||
public readonly string $value,
|
||||
private readonly int $minutes,
|
||||
) {}
|
||||
|
||||
public static function fromNullable(?string $window): self
|
||||
{
|
||||
return match (trim((string) $window)) {
|
||||
self::LastHour => new self(self::LastHour, 60),
|
||||
self::LastWeek => new self(self::LastWeek, 10080),
|
||||
default => new self(self::LastDay, 1440),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return [
|
||||
self::LastHour => 'Last hour',
|
||||
self::LastDay => 'Last 24 hours',
|
||||
self::LastWeek => 'Last 7 days',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowed(): array
|
||||
{
|
||||
return array_keys(self::options());
|
||||
}
|
||||
|
||||
public function startAt(?CarbonImmutable $now = null): CarbonImmutable
|
||||
{
|
||||
$now ??= CarbonImmutable::now();
|
||||
|
||||
return $now->subMinutes($this->minutes);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,13 @@
|
||||
'ttl_minutes' => (int) env('BREAK_GLASS_TTL_MINUTES', 15),
|
||||
],
|
||||
|
||||
'system_console' => [
|
||||
'stuck_thresholds' => [
|
||||
'queued_minutes' => (int) env('TENANTPILOT_SYSTEM_CONSOLE_STUCK_QUEUED_MINUTES', 15),
|
||||
'running_minutes' => (int) env('TENANTPILOT_SYSTEM_CONSOLE_STUCK_RUNNING_MINUTES', 30),
|
||||
],
|
||||
],
|
||||
|
||||
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
||||
|
||||
'supported_policy_types' => [
|
||||
|
||||
@ -34,6 +34,10 @@ public function run(): void
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::USE_BREAK_GLASS,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
PlatformCapabilities::OPERATIONS_MANAGE,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,129 @@
|
||||
@php
|
||||
/** @var \App\Models\Tenant $tenant */
|
||||
$tenant = $this->tenant;
|
||||
$providerConnections = $this->providerConnections();
|
||||
$permissions = $this->tenantPermissions();
|
||||
$runs = $this->recentRuns();
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
{{ $tenant->name }}
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Workspace: {{ $tenant->workspace?->name ?? 'Unknown' }}
|
||||
</x-slot>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->color"
|
||||
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->icon"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($tenant->external_id)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">External ID: {{ $tenant->external_id }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$this->adminTenantUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open in /admin
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Connectivity signals
|
||||
</x-slot>
|
||||
|
||||
@if ($providerConnections->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No provider connections found.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($providerConnections as $connection)
|
||||
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-gray-950 dark:text-white">{{ $connection->provider }}</span>
|
||||
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->color"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->color"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($connection->is_default)
|
||||
<x-filament::badge color="info">Default</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Permission signals
|
||||
</x-slot>
|
||||
|
||||
@if ($permissions->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No cached permission checks available.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($permissions as $permission)
|
||||
<div class="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<span class="font-medium text-gray-950 dark:text-white">{{ $permission->permission_key }}</span>
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantPermissionStatus, (string) $permission->status)->color"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantPermissionStatus, (string) $permission->status)->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Recent operations
|
||||
</x-slot>
|
||||
|
||||
@if ($runs->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No recent operation runs for this tenant.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($runs as $run)
|
||||
<a
|
||||
href="{{ $run['url'] }}"
|
||||
class="block rounded-lg border border-gray-200 px-4 py-3 hover:border-primary-400 hover:bg-gray-50 dark:border-white/10 dark:hover:border-primary-500 dark:hover:bg-white/5"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-950 dark:text-white">#{{ $run['id'] }} · {{ $run['label'] }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ $run['started'] }}</span>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$this->runsUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open operations runs
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,89 @@
|
||||
@php
|
||||
/** @var \App\Models\Workspace $workspace */
|
||||
$workspace = $this->workspace;
|
||||
$tenants = $this->workspaceTenants();
|
||||
$runs = $this->recentRuns();
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
{{ $workspace->name }}
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Workspace #{{ (int) $workspace->getKey() }}
|
||||
</x-slot>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tenants</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-950 dark:text-white">{{ number_format((int) $workspace->tenants_count) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$this->adminWorkspaceUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open in /admin
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Tenants summary
|
||||
</x-slot>
|
||||
|
||||
@if ($tenants->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No tenants are attached to this workspace.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($tenants as $tenant)
|
||||
<a
|
||||
href="{{ \App\Support\System\SystemDirectoryLinks::tenantDetail($tenant) }}"
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3 hover:border-primary-400 hover:bg-gray-50 dark:border-white/10 dark:hover:border-primary-500 dark:hover:bg-white/5"
|
||||
>
|
||||
<span class="font-medium text-gray-950 dark:text-white">{{ $tenant->name }}</span>
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->color"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->label }}
|
||||
</x-filament::badge>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Recent operations
|
||||
</x-slot>
|
||||
|
||||
@if ($runs->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No recent operation runs for this workspace.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($runs as $run)
|
||||
<a
|
||||
href="{{ $run['url'] }}"
|
||||
class="block rounded-lg border border-gray-200 px-4 py-3 hover:border-primary-400 hover:bg-gray-50 dark:border-white/10 dark:hover:border-primary-500 dark:hover:bg-white/5"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-950 dark:text-white">#{{ $run['id'] }} · {{ $run['label'] }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ $run['started'] }}</span>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$this->runsUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open operations runs
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -2,13 +2,6 @@
|
||||
/** @var \App\Models\OperationRun $run */
|
||||
$run = $this->run;
|
||||
|
||||
$scope = (string) data_get($run->context, 'runbook.scope', 'unknown');
|
||||
$targetTenantId = data_get($run->context, 'runbook.target_tenant_id');
|
||||
$reasonCode = data_get($run->context, 'reason.reason_code');
|
||||
$reasonText = data_get($run->context, 'reason.reason_text');
|
||||
|
||||
$platformInitiator = data_get($run->context, 'platform_initiator', []);
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||
(string) $run->status,
|
||||
@ -21,13 +14,12 @@
|
||||
)
|
||||
: null;
|
||||
|
||||
$summaryCounts = $run->summary_counts;
|
||||
$hasSummary = is_array($summaryCounts) && count($summaryCounts) > 0;
|
||||
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
$hasSummary = count($summaryCounts) > 0;
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
{{-- Run header --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Run #{{ (int) $run->getKey() }}
|
||||
@ -57,102 +49,74 @@
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Key details --}}
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Scope</dt>
|
||||
<dd class="mt-1">
|
||||
@if ($scope === 'single_tenant')
|
||||
<x-filament::badge color="info" size="sm">
|
||||
Single tenant {{ is_numeric($targetTenantId) ? '#'.(int) $targetTenantId : '' }}
|
||||
</x-filament::badge>
|
||||
@elseif ($scope === 'all_tenants')
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
All tenants
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="text-sm font-medium text-gray-950 dark:text-white">{{ $scope }}</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Workspace</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->workspace?->name ?? 'Unknown workspace' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Started</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->started_at?->toDayDateTimeString() ?? '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tenant</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->tenant?->name ?? 'Tenantless' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Completed</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->completed_at?->toDayDateTimeString() ?? '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Started</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->started_at?->toDayDateTimeString() ?? '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Initiator</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ (string) ($run->initiator_name ?? '—') }}
|
||||
@if (is_array($platformInitiator) && ($platformInitiator['email'] ?? null))
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ (string) $platformInitiator['email'] }}</div>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Completed</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->completed_at?->toDayDateTimeString() ?? '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{{-- Reason --}}
|
||||
@if (is_string($reasonCode) && is_string($reasonText) && trim($reasonCode) !== '' && trim($reasonText) !== '')
|
||||
<div class="flex items-start gap-3 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<x-heroicon-m-document-text class="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Initiator</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ (string) ($run->initiator_name ?? '—') }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Reason</span>
|
||||
<div class="mt-1 text-sm text-gray-950 dark:text-white">
|
||||
<x-filament::badge color="gray" size="sm">{{ $reasonCode }}</x-filament::badge>
|
||||
<span class="ml-1">{{ $reasonText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Runbooks</dt>
|
||||
<dd class="mt-1 text-sm">
|
||||
<x-filament::link href="{{ \App\Filament\System\Pages\Ops\Runbooks::getUrl(panel: 'system') }}">
|
||||
Go to runbooks
|
||||
</x-filament::link>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- Summary counts --}}
|
||||
@if ($hasSummary)
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Summary counts
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
@foreach ($summaryCounts as $key => $value)
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ \Illuminate\Support\Str::headline((string) $key) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xl font-bold text-gray-950 dark:text-white">
|
||||
{{ is_numeric($value) ? number_format((int) $value) : $value }}
|
||||
</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary class="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
Show raw JSON
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
@include('filament.partials.json-viewer', ['value' => $summaryCounts])
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
@foreach ($summaryCounts as $key => $value)
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ \Illuminate\Support\Str::headline((string) $key) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xl font-bold text-gray-950 dark:text-white">
|
||||
{{ is_numeric($value) ? number_format((int) $value) : $value }}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Failures --}}
|
||||
@if (! empty($run->failure_summary))
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@ -165,15 +129,5 @@
|
||||
@include('filament.partials.json-viewer', ['value' => $run->failure_summary])
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Context --}}
|
||||
<x-filament::section collapsible :collapsed="true">
|
||||
<x-slot name="heading">
|
||||
Context (raw)
|
||||
</x-slot>
|
||||
|
||||
@include('filament.partials.json-viewer', ['value' => $run->context ?? []])
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
{{-- Stats widgets --}}
|
||||
@if (method_exists($this, 'getHeaderWidgets'))
|
||||
<x-filament-widgets::widgets
|
||||
:widgets="$this->getVisibleHeaderWidgets()"
|
||||
:columns="1"
|
||||
/>
|
||||
@endif
|
||||
|
||||
{{-- Purpose box --}}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
<p class="font-medium">Purpose</p>
|
||||
@ -9,5 +18,50 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Workspace table --}}
|
||||
{{ $this->table }}
|
||||
|
||||
{{-- Recent break-glass actions --}}
|
||||
@php
|
||||
$recentActions = $this->getRecentBreakGlassActions();
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Recent break-glass actions
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Last 10 break-glass audit log entries.
|
||||
</x-slot>
|
||||
|
||||
@if (empty($recentActions))
|
||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
||||
No break-glass actions recorded yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
@foreach ($recentActions as $entry)
|
||||
<div class="flex items-center justify-between gap-4 px-1 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $entry['action'] }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
by {{ $entry['actor'] }}
|
||||
@if ($entry['workspace'])
|
||||
· Workspace: {{ $entry['workspace'] }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['recorded_at'] }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,41 @@
|
||||
<x-filament-widgets::widget>
|
||||
@php
|
||||
$health = $this->getHealthData();
|
||||
@endphp
|
||||
|
||||
<div @class([
|
||||
'flex items-center gap-3 rounded-xl border px-4 py-3',
|
||||
'border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-950/30' => $health['level'] === 'healthy',
|
||||
'border-yellow-300 bg-yellow-50 dark:border-yellow-700 dark:bg-yellow-950/30' => $health['level'] === 'warning',
|
||||
'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-950/30' => $health['level'] === 'critical',
|
||||
])>
|
||||
<x-filament::icon
|
||||
:icon="$health['icon']"
|
||||
@class([
|
||||
'h-8 w-8',
|
||||
'text-green-600 dark:text-green-400' => $health['level'] === 'healthy',
|
||||
'text-yellow-600 dark:text-yellow-400' => $health['level'] === 'warning',
|
||||
'text-red-600 dark:text-red-400' => $health['level'] === 'critical',
|
||||
])
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div @class([
|
||||
'text-sm font-semibold',
|
||||
'text-green-800 dark:text-green-200' => $health['level'] === 'healthy',
|
||||
'text-yellow-800 dark:text-yellow-200' => $health['level'] === 'warning',
|
||||
'text-red-800 dark:text-red-200' => $health['level'] === 'critical',
|
||||
])>
|
||||
{{ $health['label'] }}
|
||||
</div>
|
||||
@if ($health['level'] !== 'healthy')
|
||||
<div class="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ $health['failed'] }} failed · {{ $health['stuck'] }} stuck (last 24h)
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
No failures or stuck runs in the last 24 hours
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
@ -0,0 +1,41 @@
|
||||
<x-filament-widgets::widget>
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Recently failed operations
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Latest failures in {{ $windowLabel }}. Click any run for the canonical detail view.
|
||||
</x-slot>
|
||||
|
||||
@if ($runs->isEmpty())
|
||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
||||
No failed operations in the selected time window.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($runs as $run)
|
||||
<a
|
||||
href="{{ $run['url'] }}"
|
||||
class="block rounded-lg border border-gray-200 px-4 py-3 transition hover:border-primary-400 hover:bg-gray-50 dark:border-white/10 dark:hover:border-primary-500 dark:hover:bg-white/5"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="font-medium text-gray-950 dark:text-white">
|
||||
#{{ $run['id'] }} · {{ $run['operation'] }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ $run['created_at'] }}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $run['tenant'] }}</div>
|
||||
<div class="mt-2 text-sm text-danger-700 dark:text-danger-400">{{ $run['failure_message'] }}</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$runsUrl" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open all runs
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-widgets::widget>
|
||||
@ -0,0 +1,46 @@
|
||||
<x-filament-widgets::widget>
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Top offenders
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Highest failed-operation clusters in {{ $windowLabel }}.
|
||||
</x-slot>
|
||||
|
||||
@if ($offenders->isEmpty())
|
||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
||||
No failed operations in the selected time window.
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-white/10">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
<th class="px-3 py-2">Workspace</th>
|
||||
<th class="px-3 py-2">Tenant</th>
|
||||
<th class="px-3 py-2">Operation</th>
|
||||
<th class="px-3 py-2 text-right">Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5">
|
||||
@foreach ($offenders as $offender)
|
||||
<tr>
|
||||
<td class="px-3 py-2 font-medium text-gray-950 dark:text-white">{{ $offender['workspace_label'] }}</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{{ $offender['tenant_label'] }}</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{{ $offender['operation_label'] }}</td>
|
||||
<td class="px-3 py-2 text-right font-semibold text-danger-600 dark:text-danger-400">{{ number_format($offender['failed_count']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$runsUrl" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open all runs
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-widgets::widget>
|
||||
@ -0,0 +1,39 @@
|
||||
# Specification Quality Checklist: System Console Control Tower
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-27
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
|
||||
- Validation pass: Spec + tasks updated after consistency review to:
|
||||
- Disambiguate what “Audit log?” means in the UI Action Matrix (Access Logs surface, not per-page view logging).
|
||||
- Lock v1 scope: raw error/context drilldowns are not present; export is deferred.
|
||||
- Ensure Runbooks navigation/shortcuts (FR-007) is explicitly verified in tasks.
|
||||
@ -0,0 +1,372 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: System Console Control Tower (Spec 114)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Planning contract for System Console Control Tower read models.
|
||||
|
||||
NOTE: Filament/Livewire pages render server-side. This OpenAPI file documents
|
||||
the intended query surfaces as if they were JSON endpoints to keep fields
|
||||
and filtering semantics explicit during implementation.
|
||||
servers:
|
||||
- url: /system
|
||||
paths:
|
||||
/dashboard:
|
||||
get:
|
||||
summary: Control Tower KPIs
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
required: false
|
||||
responses:
|
||||
'200':
|
||||
description: KPI + top offenders
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ControlTowerResponse'
|
||||
/directory/workspaces:
|
||||
get:
|
||||
summary: Workspaces directory
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: health
|
||||
schema:
|
||||
type: string
|
||||
enum: [ok, warn, critical, unknown]
|
||||
responses:
|
||||
'200':
|
||||
description: Workspaces list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkspaceListResponse'
|
||||
/directory/workspaces/{workspaceId}:
|
||||
get:
|
||||
summary: Workspace detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: workspaceId
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Workspace detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkspaceDetailResponse'
|
||||
/directory/tenants:
|
||||
get:
|
||||
summary: Tenants directory
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: workspace_id
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Tenants list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantListResponse'
|
||||
/directory/tenants/{tenantId}:
|
||||
get:
|
||||
summary: Tenant detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenantId
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantDetailResponse'
|
||||
/ops/runs:
|
||||
get:
|
||||
summary: Global operation runs
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
enum: [queued, running, completed]
|
||||
- in: query
|
||||
name: outcome
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: type
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: workspace_id
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: tenant_id
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Runs list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RunListResponse'
|
||||
/ops/runs/{runId}:
|
||||
get:
|
||||
summary: Canonical run detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: runId
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Run detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RunDetailResponse'
|
||||
/ops/failures:
|
||||
get:
|
||||
summary: Failed runs (prefilter)
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
responses:
|
||||
'200':
|
||||
description: Failed runs list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RunListResponse'
|
||||
/ops/stuck:
|
||||
get:
|
||||
summary: Stuck runs (prefilter)
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
responses:
|
||||
'200':
|
||||
description: Stuck runs list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RunListResponse'
|
||||
/security/access-logs:
|
||||
get:
|
||||
summary: Access logs
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
- in: query
|
||||
name: actor_id
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
enum: [success, failure]
|
||||
responses:
|
||||
'200':
|
||||
description: Access logs list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AccessLogListResponse'
|
||||
components:
|
||||
schemas:
|
||||
ControlTowerResponse:
|
||||
type: object
|
||||
required: [window, kpis, top_offenders]
|
||||
properties:
|
||||
window: { type: string }
|
||||
kpis:
|
||||
type: object
|
||||
additionalProperties: { type: integer }
|
||||
top_offenders:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [dimension, id, label, failed_count]
|
||||
properties:
|
||||
dimension: { type: string, enum: [tenant, workspace, run_type] }
|
||||
id: { type: integer }
|
||||
label: { type: string }
|
||||
failed_count: { type: integer }
|
||||
WorkspaceListResponse:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WorkspaceSummary'
|
||||
WorkspaceDetailResponse:
|
||||
type: object
|
||||
required: [workspace]
|
||||
properties:
|
||||
workspace:
|
||||
$ref: '#/components/schemas/WorkspaceSummary'
|
||||
tenants:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TenantSummary'
|
||||
TenantListResponse:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TenantSummary'
|
||||
TenantDetailResponse:
|
||||
type: object
|
||||
required: [tenant]
|
||||
properties:
|
||||
tenant:
|
||||
$ref: '#/components/schemas/TenantSummary'
|
||||
provider_connections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProviderConnectionSummary'
|
||||
permissions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TenantPermissionSummary'
|
||||
recent_runs:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RunSummary'
|
||||
RunListResponse:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RunSummary'
|
||||
RunDetailResponse:
|
||||
type: object
|
||||
required: [run]
|
||||
properties:
|
||||
run:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RunSummary'
|
||||
- type: object
|
||||
properties:
|
||||
summary_counts:
|
||||
type: object
|
||||
additionalProperties: { type: integer }
|
||||
failure_summary:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RunFailure'
|
||||
context:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
AccessLogListResponse:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AccessLogEntry'
|
||||
WorkspaceSummary:
|
||||
type: object
|
||||
required: [id, name, slug]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
slug: { type: string }
|
||||
tenant_count: { type: integer }
|
||||
health: { type: string, enum: [ok, warn, critical, unknown] }
|
||||
last_activity_at: { type: string, format: date-time, nullable: true }
|
||||
TenantSummary:
|
||||
type: object
|
||||
required: [id, external_id, name, workspace_id]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
external_id: { type: string }
|
||||
name: { type: string }
|
||||
workspace_id: { type: integer }
|
||||
status: { type: string }
|
||||
environment: { type: string, nullable: true }
|
||||
health: { type: string, enum: [ok, warn, critical, unknown] }
|
||||
last_activity_at: { type: string, format: date-time, nullable: true }
|
||||
ProviderConnectionSummary:
|
||||
type: object
|
||||
required: [provider, is_default]
|
||||
properties:
|
||||
provider: { type: string }
|
||||
is_default: { type: boolean }
|
||||
last_health_check_at: { type: string, format: date-time, nullable: true }
|
||||
health: { type: string, nullable: true }
|
||||
TenantPermissionSummary:
|
||||
type: object
|
||||
required: [key, status]
|
||||
properties:
|
||||
key: { type: string }
|
||||
status: { type: string }
|
||||
last_checked_at: { type: string, format: date-time, nullable: true }
|
||||
RunSummary:
|
||||
type: object
|
||||
required: [id, workspace_id, type, status, outcome, created_at]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
workspace_id: { type: integer }
|
||||
tenant_id: { type: integer, nullable: true }
|
||||
type: { type: string }
|
||||
status: { type: string }
|
||||
outcome: { type: string }
|
||||
initiator_name: { type: string }
|
||||
created_at: { type: string, format: date-time }
|
||||
started_at: { type: string, format: date-time, nullable: true }
|
||||
completed_at: { type: string, format: date-time, nullable: true }
|
||||
RunFailure:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: { type: string }
|
||||
reason_code: { type: string, nullable: true }
|
||||
message: { type: string }
|
||||
AccessLogEntry:
|
||||
type: object
|
||||
required: [id, recorded_at, action, status]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
recorded_at: { type: string, format: date-time }
|
||||
action: { type: string }
|
||||
status: { type: string }
|
||||
actor_id: { type: integer, nullable: true }
|
||||
actor_email: { type: string, nullable: true }
|
||||
actor_name: { type: string, nullable: true }
|
||||
ip: { type: string, nullable: true }
|
||||
user_agent: { type: string, nullable: true }
|
||||
85
specs/114-system-console-control-tower/data-model.md
Normal file
85
specs/114-system-console-control-tower/data-model.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Phase 1 — Data Model (Spec 114: System Console Control Tower)
|
||||
|
||||
This feature is primarily **read-only UI** over existing platform/ops metadata.
|
||||
|
||||
## Entities (existing)
|
||||
|
||||
### Workspace (`workspaces`)
|
||||
- Purpose: group tenants; scope boundary for tenant plane.
|
||||
- Relevant fields: `id`, `name`, `slug`, timestamps.
|
||||
- Relationships:
|
||||
- `Workspace::tenants()`
|
||||
- `Workspace::memberships()`
|
||||
|
||||
### Tenant (`tenants`)
|
||||
- Purpose: customer tenant inventory + onboarding/health metadata.
|
||||
- Relevant fields (high level):
|
||||
- `id`, `external_id`, `name`, `workspace_id`, `status`, `environment`
|
||||
- RBAC signals: `rbac_last_checked_at`, `rbac_last_setup_at`, `rbac_canary_results`, `rbac_last_warnings`
|
||||
- `metadata` (array)
|
||||
- Relationships:
|
||||
- `Tenant::providerConnections()` → `provider_connections`
|
||||
- `Tenant::permissions()` → `tenant_permissions`
|
||||
- `Tenant::auditLogs()` → `audit_logs`
|
||||
|
||||
### ProviderConnection (`provider_connections`)
|
||||
- Purpose: connectivity + health-check metadata for external provider.
|
||||
- Relevant fields: `provider`, `is_default`, `scopes_granted`, `last_health_check_at`, `metadata`.
|
||||
|
||||
### TenantPermission (`tenant_permissions`)
|
||||
- Purpose: cached/recorded permission checks.
|
||||
- Relevant fields: `key` (permission name), `status`, `details`, `last_checked_at`.
|
||||
|
||||
### OperationRun (`operation_runs`)
|
||||
- Purpose: canonical operations observability record (non-negotiable per constitution).
|
||||
- Relevant fields:
|
||||
- identity/scope: `workspace_id` (NOT NULL), `tenant_id` (nullable), `user_id` (nullable), `initiator_name`
|
||||
- lifecycle: `type`, `status` (`queued|running|completed`), `outcome` (`pending|succeeded|failed|canceled|…`)
|
||||
- audit UX: `summary_counts` (numeric-only keys), `failure_summary` (sanitized bounded array), `context` (sanitized/limited)
|
||||
- timing: `created_at`, `started_at`, `completed_at`
|
||||
|
||||
### AuditLog (`audit_logs`)
|
||||
- Purpose: security/audit trail.
|
||||
- Relevant fields: `workspace_id`, `tenant_id` (nullable), `actor_*`, `action`, `status`, `metadata` (sanitized), `recorded_at`.
|
||||
- System console relevant actions (already emitted):
|
||||
- `platform.auth.login`
|
||||
- `platform.break_glass.enter|exit|expired`
|
||||
|
||||
## Derived/Computed concepts (new, no new table)
|
||||
|
||||
### Time window
|
||||
- Enumerated: `1h`, `24h` (default), `7d`.
|
||||
- Used for Control Tower and for failures/stuck scoping.
|
||||
|
||||
### “Stuck” run classification
|
||||
- Definition: a run is “stuck” when:
|
||||
- `status=queued` and `created_at <= now() - queued_threshold_minutes` AND `started_at IS NULL`, OR
|
||||
- `status=running` and `started_at <= now() - running_threshold_minutes`
|
||||
- Thresholds are configurable (v1):
|
||||
- `system_console.stuck_thresholds.queued_minutes`
|
||||
- `system_console.stuck_thresholds.running_minutes`
|
||||
|
||||
### Tenant/workspace health badge
|
||||
- “Worst wins” aggregation over signals:
|
||||
- Tenant status (active/onboarding/archived)
|
||||
- Provider connection health/status
|
||||
- Permission status
|
||||
- Recent failed/stuck runs within the time window
|
||||
- Display-only; does not mutate state.
|
||||
|
||||
## Validation rules
|
||||
- Any operator-provided reason/note (break-glass, mark investigated): min length 5, max length 500.
|
||||
- Filters: only allow known enum values (time window, run status/outcome).
|
||||
|
||||
## Storage/indexing plan (Phase 2 tasks will implement)
|
||||
- `operation_runs`:
|
||||
- Indexes to support windowed queries and grouping:
|
||||
- `(workspace_id, created_at)`
|
||||
- `(tenant_id, created_at)`
|
||||
- optional: `(status, outcome, created_at)` and `(type, created_at)` depending on explain plans
|
||||
- `audit_logs`:
|
||||
- `(action, recorded_at)` and `(actor_id, recorded_at)` for Access Logs filters
|
||||
|
||||
## Notes on data minimization
|
||||
- Use `RunFailureSanitizer` + `SummaryCountsNormalizer` contracts.
|
||||
- Avoid rendering raw `context` by default; when displayed, cap size and redact sensitive keys.
|
||||
167
specs/114-system-console-control-tower/plan.md
Normal file
167
specs/114-system-console-control-tower/plan.md
Normal file
@ -0,0 +1,167 @@
|
||||
# Implementation Plan: System Console Control Tower (Spec 114)
|
||||
|
||||
**Branch**: `114-system-console-control-tower` | **Date**: 2026-02-27
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a platform-only `/system` Control Tower that provides:
|
||||
|
||||
- Global health KPIs + top offenders (windowed)
|
||||
- Cross-workspace Directory (workspaces + tenants) with health signals
|
||||
- Global Operations triage (runs + failures + stuck) with canonical run detail
|
||||
- Minimal Access Logs (platform auth + break-glass)
|
||||
|
||||
Approach: extend the existing Filament System panel and reuse existing read models (`OperationRun`, `AuditLog`, `Tenant`, `Workspace`) with DB-only queries and strict data minimization/sanitization.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5 (Livewire v4), Pest v4, Laravel Sail
|
||||
**Storage**: PostgreSQL
|
||||
**Testing**: Pest v4
|
||||
**Target Platform**: Web (Filament/Livewire)
|
||||
**Project Type**: web
|
||||
**Performance Goals**: p95 < 1.0s for `/system` list/index pages at typical volumes
|
||||
**Constraints**: DB-only at render time; strict data minimization; no cross-plane session bridging
|
||||
**Scale/Scope**: cross-workspace platform operator views; growing `operation_runs` volumes
|
||||
|
||||
**Non-negotiables**
|
||||
|
||||
- `/system` is a separate plane from `/admin`.
|
||||
- Wrong plane / unauthenticated: behave as “not found” (404).
|
||||
- Platform user missing capability: forbidden (403).
|
||||
- DB-only at render time for `/system` pages (no Microsoft Graph calls while rendering).
|
||||
- Data minimization: no secrets/tokens; failures and audit context are sanitized.
|
||||
- Mutating actions are confirmed + audited.
|
||||
|
||||
**Spec source**: `specs/114-system-console-control-tower/spec.md`
|
||||
|
||||
## Constitution Check (Pre-design)
|
||||
|
||||
PASS.
|
||||
|
||||
- Inventory-first + read/write separation: this feature is read-first; v1 manages ops with strict guardrails.
|
||||
- Graph contract isolation: no render-time Graph calls; any future sync work goes through existing Graph client contracts.
|
||||
- Deterministic capabilities: capability checks use a registry (no raw strings).
|
||||
- RBAC-UX semantics: 404 vs 403 behavior preserved.
|
||||
- Ops observability: reuse `OperationRun` lifecycle via `OperationRunService`.
|
||||
- Data minimization: `RunFailureSanitizer` + `AuditContextSanitizer` are the contract.
|
||||
- Filament action safety: destructive/mutating actions require confirmation.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/114-system-console-control-tower/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
└── contracts/
|
||||
└── system-console-control-tower.openapi.yaml
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ └── System/
|
||||
│ └── Pages/
|
||||
├── Models/
|
||||
├── Services/
|
||||
└── Support/
|
||||
|
||||
config/
|
||||
database/
|
||||
routes/
|
||||
tests/
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web application. System Console features live as Filament Pages under `app/Filament/System/Pages` using existing Eloquent models.
|
||||
|
||||
## Phase 0 — Research (Complete)
|
||||
|
||||
Output artifact:
|
||||
|
||||
- `specs/114-system-console-control-tower/research.md`
|
||||
|
||||
Resolved items:
|
||||
|
||||
- System panel already exists and is isolated by guard + session cookie middleware.
|
||||
- Existing audit stream already captures platform auth and break-glass events.
|
||||
- Existing ops primitives (`OperationRun`, sanitizers, links) are sufficient and should be reused.
|
||||
|
||||
## Phase 1 — Design & Contracts (Complete)
|
||||
|
||||
Output artifacts:
|
||||
|
||||
- `specs/114-system-console-control-tower/data-model.md`
|
||||
- `specs/114-system-console-control-tower/contracts/system-console-control-tower.openapi.yaml`
|
||||
- `specs/114-system-console-control-tower/quickstart.md`
|
||||
|
||||
Post-design Constitution Check:
|
||||
|
||||
- PASS (design remains DB-only, keeps plane separation, uses sanitization contracts, and Spec 114 documents UX-001 empty-state CTA expectations + v1 drilldown scope).
|
||||
|
||||
## Phase 2 — Implementation Planning (for `tasks.md` later)
|
||||
|
||||
This section outlines the implementation chunks and acceptance criteria that will become `tasks.md`.
|
||||
|
||||
### 2.1 RBAC + capabilities
|
||||
|
||||
- Extend `App\Support\Auth\PlatformCapabilities` to include Spec 114 capabilities.
|
||||
- Ensure all new `/system` pages check capabilities via the registry (no raw strings).
|
||||
- Keep 404/403 semantics aligned with the spec decisions.
|
||||
|
||||
### 2.2 Information architecture (/system routes)
|
||||
|
||||
- Dashboard (KPIs): global aggregated view, windowed.
|
||||
- Directory:
|
||||
- Workspaces index + workspace detail.
|
||||
- Tenants index + tenant detail.
|
||||
- Ops:
|
||||
- Runs list.
|
||||
- Failures list (prefiltered/saved view).
|
||||
- Stuck list (queued + running thresholds).
|
||||
- Canonical run detail: remove current runbook-only scoping so it can show any `OperationRun` (still authorization-checked).
|
||||
- Security:
|
||||
- Access logs list (platform login + break-glass only for v1).
|
||||
|
||||
### 2.3 Ops triage actions (v1 manage)
|
||||
|
||||
- Implement manage actions with capability gating (`platform.operations.manage`).
|
||||
- Actions:
|
||||
- Retry run: only when retryable.
|
||||
- Cancel run: only when cancelable.
|
||||
- Mark investigated: requires reason.
|
||||
- All actions:
|
||||
- Execute via Filament `Action::make(...)->action(...)`.
|
||||
- Include `->requiresConfirmation()`.
|
||||
- Produce an `AuditLog` entry with stable action IDs and sanitized context.
|
||||
|
||||
### 2.4 Configuration
|
||||
|
||||
- Add config keys for “stuck” thresholds (queued minutes, running minutes).
|
||||
- Ensure defaults are safe and can be overridden per environment.
|
||||
|
||||
### 2.5 Testing (Pest)
|
||||
|
||||
- New page access tests:
|
||||
- non-platform users get 404.
|
||||
- platform users without capability get 403.
|
||||
- System auth/security regression verification:
|
||||
- `/system` login is rate-limited and failed attempts are audited via `platform.auth.login` (existing coverage in `tests/Feature/System/Spec113/SystemLoginThrottleTest.php`).
|
||||
- break-glass mode renders a persistent banner and audits transitions (`platform.break_glass.*`) (existing coverage in `tests/Feature/Auth/BreakGlassModeTest.php`).
|
||||
- Access logs surface tests:
|
||||
- `platform.auth.login` and `platform.break_glass.*` appear.
|
||||
- Manage action tests:
|
||||
- capability required.
|
||||
- audit entries written.
|
||||
- non-retryable/non-cancelable runs block with clear feedback.
|
||||
|
||||
### 2.6 Formatting
|
||||
|
||||
- Run `vendor/bin/sail bin pint --dirty --format agent` before finalizing implementation.
|
||||
41
specs/114-system-console-control-tower/quickstart.md
Normal file
41
specs/114-system-console-control-tower/quickstart.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Quickstart (Spec 114: System Console Control Tower)
|
||||
|
||||
## Prereqs
|
||||
- Docker running
|
||||
- Laravel Sail
|
||||
|
||||
## Start the app
|
||||
- `vendor/bin/sail up -d`
|
||||
- `vendor/bin/sail composer install`
|
||||
- `vendor/bin/sail artisan migrate`
|
||||
|
||||
## Seed a platform operator (recommended)
|
||||
The repo includes `Database\Seeders\PlatformUserSeeder`, which creates:
|
||||
- Workspace `default`
|
||||
- Tenant with `external_id=platform`
|
||||
- Platform user `operator@tenantpilot.io` (password: `password`) with baseline system capabilities
|
||||
|
||||
Run:
|
||||
- `vendor/bin/sail artisan db:seed`
|
||||
|
||||
## Open the System console
|
||||
- Visit `/system`
|
||||
- Login with:
|
||||
- Email: `operator@tenantpilot.io`
|
||||
- Password: `password`
|
||||
|
||||
## Validate key Spec 114 surfaces
|
||||
- Control Tower dashboard: `/system?window=24h` (switch between `1h`, `24h`, `7d`)
|
||||
- Global operations:
|
||||
- Runs: `/system/ops/runs`
|
||||
- Failures: `/system/ops/failures`
|
||||
- Stuck: `/system/ops/stuck`
|
||||
- Directory:
|
||||
- Workspaces: `/system/directory/workspaces`
|
||||
- Tenants: `/system/directory/tenants`
|
||||
- Security:
|
||||
- Access logs: `/system/security/access-logs`
|
||||
|
||||
## Notes
|
||||
- `/system` uses the `platform` guard and a separate session cookie from `/admin`.
|
||||
- The System console should remain DB-only at render time (no Graph calls on page load).
|
||||
97
specs/114-system-console-control-tower/research.md
Normal file
97
specs/114-system-console-control-tower/research.md
Normal file
@ -0,0 +1,97 @@
|
||||
# Phase 0 — Research (Spec 114: System Console Control Tower)
|
||||
|
||||
## Goal
|
||||
Deliver a platform-operator “/system” control plane that is **strictly separated** from “/admin”, is **metadata-only by default**, and provides fast routing into canonical `OperationRun` detail.
|
||||
|
||||
## Existing primitives (reuse)
|
||||
|
||||
### System panel + plane separation
|
||||
- `app/Providers/Filament/SystemPanelProvider.php`
|
||||
- Panel: `id=system`, `path=system`, `authGuard('platform')`
|
||||
- Uses `UseSystemSessionCookie` to isolate sessions from `/admin`
|
||||
- Uses middleware `ensure-correct-guard:platform` and capability gate `ensure-platform-capability:<ACCESS_SYSTEM_PANEL>`
|
||||
- `app/Http/Middleware/UseSystemSessionCookie.php`
|
||||
- Implements Spec 114 clarification: separate session cookie name for `/system`
|
||||
|
||||
### Authorization semantics (404 vs 403)
|
||||
- Existing tests already enforce the clarified behavior:
|
||||
- Non-platform (wrong guard) → 404 (deny-as-not-found)
|
||||
- Platform user missing capability → 403
|
||||
|
||||
### Operation runs (Monitoring source of truth)
|
||||
- `app/Models/OperationRun.php` + migrations under `database/migrations/*operation_runs*`
|
||||
- `workspace_id` is required; `tenant_id` is nullable (supports tenantless runs)
|
||||
- `failure_summary`, `summary_counts`, `context` are JSON arrays and already used in UI
|
||||
- `app/Services/OperationRunService.php`
|
||||
- Canonical lifecycle transitions, summary-count normalization, failure sanitization
|
||||
- Has stale queued run helper (`isStaleQueuedRun()` + `failStaleQueuedRun()`)
|
||||
- Canonical System run links:
|
||||
- `app/Support/System/SystemOperationRunLinks.php` (index + view)
|
||||
|
||||
### Sanitization / data minimization
|
||||
- Failures: `app/Support/OpsUx/RunFailureSanitizer.php` (reason normalization + message redaction)
|
||||
- Audit metadata: `app/Support/Audit/AuditContextSanitizer.php` (redacts token/secret/password-like keys + bearer/JWT strings)
|
||||
|
||||
### Access logs signal source
|
||||
- `app/Models/AuditLog.php`
|
||||
- System login auditing:
|
||||
- `app/Filament/System/Pages/Auth/Login.php` writes `AuditLog` events with action `platform.auth.login`
|
||||
- Break-glass auditing:
|
||||
- `app/Services/Auth/BreakGlassSession.php` writes `platform.break_glass.enter|exit|expired`
|
||||
|
||||
## Key gaps to implement (Spec 114)
|
||||
|
||||
### Navigation/IA
|
||||
- Add System pages:
|
||||
- `/system/directory/workspaces` (+ detail)
|
||||
- `/system/directory/tenants` (+ detail)
|
||||
- `/system/ops/runs` (global) + canonical detail already exists but is currently *runbook-type scoped*
|
||||
- `/system/ops/failures` (prefilter)
|
||||
- `/system/ops/stuck` (prefilter)
|
||||
- `/system/security/access-logs`
|
||||
|
||||
### RBAC (platform capabilities)
|
||||
- `app/Support/Auth/PlatformCapabilities.php` currently contains only Ops/runbooks/break-glass/core panel access.
|
||||
- Spec 114 introduces additional capabilities (e.g. `platform.console.view`, `platform.directory.view`, `platform.operations.manage`).
|
||||
|
||||
Decision:
|
||||
- Extend `PlatformCapabilities` registry with Spec 114 capabilities and update system pages to gate via the registry constants (no raw strings).
|
||||
|
||||
### Stuck definition
|
||||
- There is a helper for “stale queued” in `OperationRunService`, but no “running too long” classification.
|
||||
|
||||
Decision:
|
||||
- Introduce configurable stuck thresholds for `queued` and `running` (minutes) under a single config namespace (e.g. `config/tenantpilot.php`), and implement stuck classification in a dedicated helper/service used by the System pages.
|
||||
|
||||
### Control Tower aggregation
|
||||
- Spec 114 requires KPIs + top offenders in a selectable time window.
|
||||
|
||||
Decision:
|
||||
- Use DB-only aggregation on `operation_runs` for the selected time window:
|
||||
- KPIs: counts by outcome/status, and “failed/stuck” counts
|
||||
- Top offenders: group by tenant/workspace for failed runs
|
||||
- Default time window: 24h; supported: 1h/24h/7d
|
||||
|
||||
## Non-functional decisions (resolving “NEEDS CLARIFICATION”)
|
||||
|
||||
### Technical context (resolved)
|
||||
- Language/runtime: PHP 8.4 (Laravel 12)
|
||||
- Admin framework: Filament v5 + Livewire v4
|
||||
- Storage: PostgreSQL (Sail locally)
|
||||
- Testing: Pest v4
|
||||
- Target: web app (server-rendered Livewire/Filament)
|
||||
|
||||
### Performance goals (assumptions, but explicit)
|
||||
- System list pages are DB-only at render time; no external calls.
|
||||
- Target: p95 < 1.0s for index pages at typical production volumes, using:
|
||||
- time-window defaults (24h)
|
||||
- pagination
|
||||
- indexes for `operation_runs(status,outcome,created_at,type,workspace_id,tenant_id)` and `audit_logs(action,recorded_at,actor_id)`
|
||||
|
||||
### Data minimization
|
||||
- Default run detail surfaces only sanitized `failure_summary` + normalized `summary_counts`.
|
||||
- `context` rendering remains sanitized/limited (avoid raw payload dumps by default).
|
||||
|
||||
## Alternatives considered
|
||||
- New “SystemOperationRun” table: rejected; existing `OperationRun` is already the canonical monitoring artifact.
|
||||
- Building Access Logs from web server logs: rejected; `AuditLog` already exists, is sanitized, and includes platform-auth events.
|
||||
167
specs/114-system-console-control-tower/spec.md
Normal file
167
specs/114-system-console-control-tower/spec.md
Normal file
@ -0,0 +1,167 @@
|
||||
# Feature Specification: System Console Control Tower (Platform Operator)
|
||||
|
||||
**Feature Branch**: `114-system-console-control-tower`
|
||||
**Created**: 2026-02-27
|
||||
**Status**: Draft
|
||||
**Input**: Spec 114 — System Console Control Tower für Plattformbetreiber
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/system` (alias) and `/system/dashboard` (Control Tower)
|
||||
- `/system/directory/workspaces` + workspace detail
|
||||
- `/system/directory/tenants` + tenant detail
|
||||
- `/system/ops/runs` + canonical run detail (`/system/ops/runs/{run}`)
|
||||
- `/system/ops/failures` (prefilter)
|
||||
- `/system/ops/stuck` (prefilter)
|
||||
- `/system/security/access-logs`
|
||||
- **Data Ownership**: Platform-owned operational metadata across workspaces/tenants (health signals, run metadata, audit/access events). No customer policy payloads, secrets, or PII are presented by default.
|
||||
- **RBAC**:
|
||||
- Access is limited to platform users only (platform guard).
|
||||
- Capability-based access:
|
||||
- `platform.console.view`
|
||||
- `platform.directory.view`
|
||||
- `platform.operations.view`
|
||||
- `platform.operations.manage` (enabled in v1)
|
||||
- `platform.runbooks.view` / `platform.runbooks.run` (integration point with Spec 113)
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Not applicable. `/system` has no tenant-context; it is platform-only.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**:
|
||||
- Any request not authenticated as a platform user is treated as “not found” (deny-as-not-found).
|
||||
- Listing and detail access is always gated by capabilities (view vs manage) and only exposes non-sensitive metadata.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-27
|
||||
|
||||
- Q: Which session isolation should v1 implement for `/system` (SR-003)? → A: Same domain, but separate session cookie name for `/system`.
|
||||
- Q: Should manage actions (Retry/Cancel/Mark investigated) be active in v1? → A: Yes. `platform.operations.manage` is in v1 with: Retry (retryable only), Cancel (supported only), Mark investigated (reason required).
|
||||
- Q: Which 404 vs 403 semantics apply for `/system`? → A: Non-platform / wrong guard returns 404; platform user missing capability returns 403.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Global Health & Triage Entry (Priority: P1)
|
||||
|
||||
As a platform operator, I want a single Control Tower view that summarizes platform health and routes me to the most urgent issues, so I can triage failures quickly without exposing customer-sensitive data.
|
||||
|
||||
**Why this priority**: This is the primary operator workflow (“what’s broken right now?”) and the first screen that enables faster incident response.
|
||||
|
||||
**Independent Test**: A platform user can open the Control Tower, see KPIs/top offenders for a selected time window, and click through to a canonical run detail page.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a platform user with `platform.console.view`, **When** they open the Control Tower, **Then** they see KPI counts and “Top offenders” summaries for the selected time window.
|
||||
2. **Given** a failed operation exists, **When** they click a “recently failed operation”, **Then** they land on the canonical run detail page.
|
||||
3. **Given** a non-platform user, **When** they request any `/system/*` URL, **Then** the system does not reveal that the console exists.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Directory for Workspaces & Tenants (Priority: P2)
|
||||
|
||||
As a platform support engineer, I want a directory of workspaces and tenants with health signals and recent activity, so I can route issues to the right tenant/workspace and quickly inspect recent operations.
|
||||
|
||||
**Why this priority**: Most incidents are tenant-scoped; fast routing depends on a reliable cross-tenant directory with minimal data exposure.
|
||||
|
||||
**Independent Test**: A platform user can list workspaces/tenants, open details, and jump to run listings filtered to that tenant/workspace.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a platform user with `platform.directory.view`, **When** they view the Workspaces index, **Then** they can sort and filter by health and activity, and navigate to workspace details.
|
||||
2. **Given** a tenant, **When** they view tenant details, **Then** they see connectivity/permissions status and recent operations as metadata-only summaries.
|
||||
3. **Given** the UI provides an “Open in /admin” link, **When** a platform user clicks it, **Then** it is a plain URL only (no auto-login, no session bridging).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Operations Triage Actions & Auditability (Priority: P3)
|
||||
|
||||
As a privileged platform operator, I want to take safe triage actions on failed or stuck operation runs (retry/cancel/mark investigated), so I can restore platform health with guardrails and complete audit trails.
|
||||
|
||||
**Why this priority**: Operational actions are high-risk; they must be permission-gated and auditable.
|
||||
|
||||
**Independent Test**: A platform user with `platform.operations.manage` can perform an allowed triage action and observe that it is recorded, while a view-only user cannot.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a platform user without `platform.operations.manage`, **When** they view failures/stuck runs, **Then** they can inspect but cannot execute triage actions.
|
||||
2. **Given** a platform user with `platform.operations.manage`, **When** they retry a retryable run, **Then** a new run is initiated and linked to the original for traceability.
|
||||
3. **Given** a triage action is destructive or high blast-radius, **When** the operator attempts it, **Then** they must explicitly confirm (and provide a reason where required) before it executes.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Large volumes of runs and tenants: list pages still load within an acceptable wait time and do not degrade into partial/inconsistent results.
|
||||
- Missing or unknown health inputs: health is shown as “Unknown” or equivalent, not as a false “OK”.
|
||||
- Stuck classification boundaries: a run right on the threshold is classified consistently.
|
||||
- Sanitization: error/context summaries never reveal tokens, secrets, or policy payloads.
|
||||
- Break-glass mode: all pages show an unmistakable banner and actions include the break-glass marker.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 — Control Tower Dashboard (Global Health)**: The system MUST provide a Control Tower dashboard showing platform health within a selectable time window (default 24h; options include 1h/24h/7d), including KPI counts and “Top offenders” summaries.
|
||||
- **FR-002 — Directory: Workspaces**: The system MUST provide a Workspaces index and workspace detail view that shows tenant counts, a health badge (OK/Warn/Critical/Unknown), last activity, and quick links to relevant views.
|
||||
- **FR-003 — Directory: Tenants**: The system MUST provide a Tenants index and tenant detail view that shows provider connectivity status, permissions status, last sync/compare summaries as counts/metadata only, and runbook shortcuts where available.
|
||||
- **FR-004 — Operations: Global Runs + Canonical Run Detail**: The system MUST provide a global operation runs view with filtering (status/type/workspace/tenant/time window/actor) and a single canonical run detail page used by all “View run” links.
|
||||
- **FR-005 — Failures View (Prefiltered)**: The system MUST provide a failures view that prefilters to failed runs and groups/summarizes failures by run type and by tenant, enabling 1–2 click routing into run details.
|
||||
- **FR-006 — Stuck Runs Definition & View**: The system MUST define and surface “stuck” runs based on configurable thresholds for “queued too long” and “running too long”, and present an operator view for investigating them. Any triage actions available from this surface MUST follow FR-006a.
|
||||
- **FR-006a — Triage Actions (v1 enabled)**: For operators with `platform.operations.manage`, the system MUST provide triage actions in failures/stuck/run detail views, constrained as follows: Retry is available only for retryable run types; Cancel is available only where the run supports cancelation; “Mark investigated” requires a reason/note.
|
||||
- **FR-007 — Runbook Shortcuts Integration**: The system MUST provide navigation to runbooks from the System Console navigation. The UI MAY provide scope-aware shortcuts from tenant/workspace/run details. If runbooks are not available yet, the UI MAY show “coming soon” placeholders.
|
||||
- **FR-008 — Access Logs (Security, minimal v1)**: The system MUST provide an access log view for platform users that supports filtering by user/time/outcome and includes login successes/failures and break-glass activation events.
|
||||
- **FR-009 — Export (optional)**: The system MAY allow exporting filtered run metadata as CSV without including sensitive context. (Deferred in v1.)
|
||||
|
||||
### Security, Privacy, and Guardrails
|
||||
|
||||
- **SR-001 — Guard Isolation**: `/system` MUST be accessible exclusively to platform users; non-platform access (wrong guard or unauthenticated) MUST behave as “not found” and MUST not reveal the presence of the console.
|
||||
- **SR-001a — 404 vs 403 Semantics**: The system MUST apply the following response semantics consistently across `/system/*`:
|
||||
- Wrong guard / unauthenticated / not a platform user → 404 (deny-as-not-found)
|
||||
- Platform user authenticated but missing required capability → 403
|
||||
- **SR-002 — Authentication Hardening**: The system MUST throttle excessive `/system` login attempts and MUST record failed attempts for later review. v1 throttle policy is: max 10 failed attempts per 60 seconds per `ip + email` (throttle key: `system-login:{ip}:{normalizedEmail}`), recording `reason` (e.g., `invalid_credentials`, `inactive`, `throttled`) under the `platform.auth.login` audit action.
|
||||
- **SR-003 — Data Minimization by Default**: `/system` MUST avoid sensitive content by default (no raw policy payloads, secrets, tokens, or PII). Only counts, status badges, and sanitized summaries are shown.
|
||||
- **SR-004 — Sensitive Drilldowns**: v1 MUST NOT provide raw error/context payload inspection in `/system`. If raw inspection is introduced later, it MUST be restricted behind elevated capability and require an operator-provided reason.
|
||||
- **SR-005 — Break-Glass Guardrails**: When break-glass mode is active, the UI MUST show a persistent banner, require a reason, and annotate actions/logs as break-glass.
|
||||
- **SR-006 — Session Isolation**: `/system` MUST use a separate session cookie name (distinct from `/admin`) to reduce cross-plane session coupling. `/system` MUST NOT reuse the customer/admin session cookie.
|
||||
- **SR-007 — Manage Action Guardrails**: Any triage action that mutates state (retry/cancel/mark investigated) MUST be restricted to `platform.operations.manage`, MUST require explicit confirmation, and MUST record an audit trail including actor, scope, target run, and operator-provided reason where applicable.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- A platform operator console exists as a separate plane from customer administration, and customer users must never see maintenance/ops screens.
|
||||
- Operation execution is routed through a single auditable run model (operator actions are “initiated” and traceable).
|
||||
- Health statuses are computed from multiple signals using a “worst wins” rule.
|
||||
|
||||
## UI Action Matrix *(mandatory when System Console UI is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Control Tower Dashboard | `/system/dashboard` | Time window switcher | “Recently failed operations” items link to run detail | None | None | View operation runs | N/A | N/A | Yes (access) | Read-only KPIs and offender summaries; no sensitive payloads |
|
||||
| Workspaces Index | `/system/directory/workspaces` | None | Click workspace name to open details | None | None | Clear filters | N/A | N/A | Yes (access) | Supports sort/filter by health/activity/tenant count |
|
||||
| Workspace Detail | `/system/directory/workspaces/{workspace}` | “View tenants”, “View runs (filtered)” | Tenant list items link to tenant detail; runs link to canonical run detail | None | None | View runs (filtered) | N/A | N/A | Yes (access) | “Open in /admin” is URL-only; no session bridging |
|
||||
| Tenants Index | `/system/directory/tenants` | None | Click tenant name to open details | None | None | Clear filters | N/A | N/A | Yes (access) | Shows health signals as badges and counts |
|
||||
| Tenant Detail | `/system/directory/tenants/{tenant}` | Runbook shortcuts (if entitled) | Recent operations list links to canonical run detail | Optional: “Run health check” / “Run sync” (max 2 visible; can be “coming soon”) | None | View operation runs | N/A | N/A | Yes | Runbook actions require confirmation and capability gating |
|
||||
| Operation Runs | `/system/ops/runs` | Filters | Row click or “View run” link to canonical run detail | “Retry” / “Cancel” (manage only; availability depends on run type/support) | “Retry selected”, “Cancel selected” (manage only; constrained) | Clear filters | N/A | N/A | Yes | Actions require explicit confirmation, may require reason, and are fully auditable |
|
||||
| Run Detail (Canonical) | `/system/ops/runs/{run}` | “Related tenant/workspace”, “Similar failures”, “Go to runbooks” | Links to filtered views | “Retry” / “Cancel” (manage only; constrained) | None | N/A | N/A | N/A | Yes | Context/error panels are sanitized by default in v1 (raw drilldowns are not available in v1) |
|
||||
| Failures View | `/system/ops/failures` | Filters | Links to canonical run detail and tenant/workspace | “Retry” (manage only; retryable only) | “Retry selected” (manage only; retryable only) | Clear filters | N/A | N/A | Yes | Pre-filter to failed; 1–2 click routing |
|
||||
| Stuck Runs View | `/system/ops/stuck` | Filters | Links to canonical run detail | “Cancel”, “Mark investigated” (manage only; cancel only if supported) | “Cancel selected” (manage only; constrained) | Clear filters | N/A | N/A | Yes | “Mark investigated” requires a note/reason |
|
||||
| Access Logs | `/system/security/access-logs` | Filters | None | None | None | Clear filters | N/A | N/A | Yes | Minimal v1 security visibility |
|
||||
|
||||
**Audit log interpretation**: In this matrix, “Audit log?” means security/audit events are visible via the Access Logs surface (login successes/failures, break-glass activation, and operator triage actions). It does not imply per-page view logging for every `/system` page.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Operation Run**: An auditable record of an operational activity, including type, scope (platform/workspace/tenant), actor, start/end timestamps, status/outcome, and a sanitized summary.
|
||||
- **Workspace**: A customer workspace container, used for grouping tenants and operational scope.
|
||||
- **Tenant**: A customer tenant within a workspace, including provider connectivity and governance signal summaries.
|
||||
- **Platform User**: An internal operator identity with capability-based authorization.
|
||||
- **Access Log**: A record of platform access and authentication-related security events.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Platform operators can identify the top failing tenant and open the related canonical run detail in ≤ 2 clicks from the Control Tower.
|
||||
- **SC-002**: The Control Tower and directory pages load in p95 < 1.0s for typical production volumes.
|
||||
- **SC-003**: In a structured review of the `/system` UI, no customer-sensitive payloads (policy content, secrets, tokens, PII) are visible by default.
|
||||
- **SC-004**: 100% of operator triage actions (retry/cancel/mark investigated) are permission-gated and leave a complete audit trail.
|
||||
- **SC-005**: Non-platform users cannot discover `/system` routes via direct URL guessing (console behaves as not found).
|
||||
201
specs/114-system-console-control-tower/tasks.md
Normal file
201
specs/114-system-console-control-tower/tasks.md
Normal file
@ -0,0 +1,201 @@
|
||||
---
|
||||
description: "Executable task breakdown for Spec 114 implementation"
|
||||
---
|
||||
|
||||
# Tasks: System Console Control Tower (Spec 114)
|
||||
|
||||
**Input**: Design documents from `specs/114-system-console-control-tower/`
|
||||
|
||||
**Docs used**:
|
||||
- `specs/114-system-console-control-tower/spec.md`
|
||||
- `specs/114-system-console-control-tower/plan.md`
|
||||
- `specs/114-system-console-control-tower/research.md`
|
||||
- `specs/114-system-console-control-tower/data-model.md`
|
||||
- `specs/114-system-console-control-tower/contracts/system-console-control-tower.openapi.yaml`
|
||||
- `specs/114-system-console-control-tower/quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest) for runtime behavior changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Structure)
|
||||
|
||||
- [X] T001 Review existing System panel primitives in `app/Providers/Filament/SystemPanelProvider.php`, System auth/security primitives in `app/Filament/System/Pages/Auth/Login.php` + `app/Services/Auth/BreakGlassSession.php`, and System tests in `tests/Feature/System/Spec113/` + `tests/Feature/Auth/BreakGlassModeTest.php` + `tests/Feature/System/OpsRunbooks/` (confirm session isolation cookie middleware, login throttling + audit trail, break-glass banner/audits, 404/403 semantics, and existing Ops-UX start-surface contract patterns)
|
||||
- [X] T002 [P] Create new System page namespaces for Spec 114 in `app/Filament/System/Pages/Directory/` and `app/Filament/System/Pages/Security/`
|
||||
- [X] T003 [P] Create new System Blade view directories for Spec 114 in `resources/views/filament/system/pages/directory/` and `resources/views/filament/system/pages/security/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [X] T004 Extend platform capability registry with Spec 114 constants in `app/Support/Auth/PlatformCapabilities.php` (add `platform.console.view`, `platform.directory.view`, `platform.operations.view`, `platform.operations.manage`; keep existing constants for compatibility)
|
||||
- [X] T005 Update seeded platform operator capabilities in `database/seeders/PlatformUserSeeder.php` to include the new Spec 114 capabilities
|
||||
- [X] T006 Add stuck threshold defaults to `config/tenantpilot.php` under `system_console.stuck_thresholds.{queued_minutes,running_minutes}` (used by `/system/ops/stuck`)
|
||||
- [X] T007 [P] Implement a typed time-window helper in `app/Support/SystemConsole/SystemConsoleWindow.php` (allowed: `1h`, `24h` default, `7d`; provides start timestamp)
|
||||
- [X] T008 [P] Implement stuck run classification helper in `app/Support/SystemConsole/StuckRunClassifier.php` (DB-only query constraints for queued/running + thresholds)
|
||||
- [X] T009 Update System panel access regression tests in `tests/Feature/System/Spec113/AuthorizationSemanticsTest.php` (if needed) to preserve the clarified rule: wrong guard / unauthenticated → 404; platform user missing page capability → 403
|
||||
- [X] T010 Add Spec 114 access semantics tests in `tests/Feature/System/Spec114/SystemConsoleAccessSemanticsTest.php` (assert 404 for tenant-guard requests across representative `/system/*` URLs and 403 for platform users missing required capabilities; also assert `/system` uses a distinct session cookie name from `/admin` to enforce SR-006)
|
||||
|
||||
**Checkpoint**: Capabilities/config/helpers/tests exist; user story work can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Global Health & Triage Entry (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Control Tower KPIs + top offenders + quick clickthrough to a canonical run detail.
|
||||
|
||||
**Independent Test**: A platform user can open `/system` (dashboard), switch time window, see KPIs/top offenders, and open a run detail.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T011 [P] [US1] Add Control Tower access + window default tests in `tests/Feature/System/Spec114/ControlTowerDashboardTest.php`
|
||||
- [X] T012 [P] [US1] Add canonical run detail access + data-minimization tests in `tests/Feature/System/Spec114/CanonicalRunDetailTest.php` (assert SR-004 v1 behavior: no raw error/context drilldowns; only sanitized summaries render)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T013 [US1] Gate the dashboard with `platform.console.view` and add a time-window switcher to header actions in `app/Filament/System/Pages/Dashboard.php`
|
||||
- [X] T014 [P] [US1] Create KPIs widget in `app/Filament/System/Widgets/ControlTowerKpis.php` (DB-only aggregation on `operation_runs` within selected window)
|
||||
- [X] T015 [P] [US1] Create “Top offenders” widget in `app/Filament/System/Widgets/ControlTowerTopOffenders.php` (group failed runs by tenant/workspace/type within window)
|
||||
- [X] T016 [P] [US1] Create “Recently failed operations” widget in `app/Filament/System/Widgets/ControlTowerRecentFailures.php` (links to canonical run detail via `app/Support/System/SystemOperationRunLinks.php`)
|
||||
- [X] T017 [US1] Register Spec 114 widgets on the System dashboard in `app/Filament/System/Pages/Dashboard.php` (ensure all widget queries are DB-only)
|
||||
- [X] T018 [US1] Convert the System runs list to *global* runs (not runbook-only) in `app/Filament/System/Pages/Ops/Runs.php` and keep the table rendering in `resources/views/filament/system/pages/ops/runs.blade.php`
|
||||
- [X] T019 [US1] Make run detail canonical (remove runbook-only + platform-workspace-only constraints) and gate it with `platform.operations.view` in `app/Filament/System/Pages/Ops/ViewRun.php`
|
||||
- [X] T020 [US1] Generalize the run detail rendering to non-runbook runs in `resources/views/filament/system/pages/ops/view-run.blade.php` (keep sanitized failures + avoid leaking sensitive context by default)
|
||||
|
||||
**Checkpoint**: US1 is shippable and independently testable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Directory for Workspaces & Tenants (Priority: P2)
|
||||
|
||||
**Goal**: Provide cross-workspace directory pages with health signals and safe links into ops views.
|
||||
|
||||
**Independent Test**: A platform user can list workspaces/tenants, open details, and jump to filtered run listings without session bridging.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T021 [P] [US2] Add workspaces directory access + listing tests in `tests/Feature/System/Spec114/DirectoryWorkspacesTest.php`
|
||||
- [X] T022 [P] [US2] Add tenants directory access + listing tests in `tests/Feature/System/Spec114/DirectoryTenantsTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T023 [US2] Add a System health badge domain (OK/Warn/Critical/Unknown) in `app/Support/Badges/BadgeDomain.php`, map it in `app/Support/Badges/BadgeCatalog.php`, and implement its mapper in `app/Support/Badges/Domains/SystemHealthBadge.php`
|
||||
- [X] T024 [P] [US2] Add badge mapping semantics tests in `tests/Feature/Badges/SystemHealthBadgeSemanticsTest.php`
|
||||
- [X] T025 [P] [US2] Add directory URL helpers in `app/Support/System/SystemDirectoryLinks.php` (workspaces/tenants index + detail URLs, plus safe “Open in /admin” URL-only links)
|
||||
- [X] T026 [US2] Implement Workspaces index page (table + filters) in `app/Filament/System/Pages/Directory/Workspaces.php` with view `resources/views/filament/system/pages/directory/workspaces.blade.php` (gate with `platform.directory.view`)
|
||||
- [X] T027 [US2] Implement Workspace detail page in `app/Filament/System/Pages/Directory/ViewWorkspace.php` with view `resources/views/filament/system/pages/directory/view-workspace.blade.php` (tenants summary + recent ops links)
|
||||
- [X] T028 [US2] Implement Tenants index page in `app/Filament/System/Pages/Directory/Tenants.php` with view `resources/views/filament/system/pages/directory/tenants.blade.php`
|
||||
- [X] T029 [US2] Implement Tenant detail page in `app/Filament/System/Pages/Directory/ViewTenant.php` with view `resources/views/filament/system/pages/directory/view-tenant.blade.php` (connectivity/permission signals + recent ops)
|
||||
- [X] T030 [US2] Ensure any “Open in /admin” links remain URL-only (no auto-login, no session bridging) in `resources/views/filament/system/pages/directory/view-workspace.blade.php` and `resources/views/filament/system/pages/directory/view-tenant.blade.php`
|
||||
|
||||
**Checkpoint**: Directory is usable and independently testable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Operations Triage Actions & Auditability (Priority: P3)
|
||||
|
||||
**Goal**: Provide failures/stuck/access-log surfaces plus safe triage actions with confirmation and audit trails.
|
||||
|
||||
**Independent Test**: A view-only platform user can inspect but cannot mutate; a manage-capable user can perform a supported triage action and an audit log entry is written.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T031 [P] [US3] Add failures view access + prefilter tests in `tests/Feature/System/Spec114/OpsFailuresViewTest.php`
|
||||
- [X] T032 [P] [US3] Add stuck view access + stuck classification boundary tests in `tests/Feature/System/Spec114/OpsStuckViewTest.php`
|
||||
- [X] T033 [P] [US3] Add access logs filtering tests in `tests/Feature/System/Spec114/AccessLogsTest.php` (assert `platform.auth.login` includes both success + failure events and includes `platform.break_glass.*` actions)
|
||||
- [X] T034 [P] [US3] Add triage action authorization + audit-write tests in `tests/Feature/System/Spec114/OpsTriageActionsTest.php` (include an Ops-UX contract regression assertion for any triage action that queues work: intent-only toast + working “View run” link + no queued database notifications, mirroring `tests/Feature/System/OpsRunbooks/`)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T035 [US3] Implement failures page in `app/Filament/System/Pages/Ops/Failures.php` and view `resources/views/filament/system/pages/ops/failures.blade.php` (prefilter failed runs; gate with `platform.operations.view`)
|
||||
- [X] T036 [US3] Implement stuck page in `app/Filament/System/Pages/Ops/Stuck.php` and view `resources/views/filament/system/pages/ops/stuck.blade.php` (use `app/Support/SystemConsole/StuckRunClassifier.php`; gate with `platform.operations.view`)
|
||||
- [X] T037 [US3] Implement access logs page in `app/Filament/System/Pages/Security/AccessLogs.php` and view `resources/views/filament/system/pages/security/access-logs.blade.php` (AuditLog list scoped to `platform.auth.login` + `platform.break_glass.*`; gate with `platform.console.view`)
|
||||
- [X] T038 [US3] Implement triage policy + execution in `app/Services/SystemConsole/OperationRunTriageService.php` (define retryable/cancelable allowlist by operation type; “mark investigated” requires reason and writes audit)
|
||||
- [X] T039 [US3] Implement system-console audit logging helper in `app/Services/SystemConsole/SystemConsoleAuditLogger.php` (wrap `app/Services/Intune/AuditLogger.php` using the `platform` tenant; stable action IDs; includes break-glass marker)
|
||||
- [X] T040 [US3] Add manage-only Filament actions (Retry/Cancel/Mark investigated) to run tables and run detail in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, and `app/Filament/System/Pages/Ops/ViewRun.php` (all mutations use `->action(...)` + `->requiresConfirmation()`, “Mark investigated” includes a required reason field)
|
||||
|
||||
**Checkpoint**: All Spec 114 operator actions are capability-gated, confirmed, and audited.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T041 [P] Run code formatting on touched files via `vendor/bin/sail` (use `vendor/bin/sail bin pint --dirty --format agent`)
|
||||
- [X] T042 Run Spec 114 focused tests via `vendor/bin/sail` in `tests/Feature/System/Spec114/`
|
||||
- [X] T043 Validate quickstart steps remain accurate in `specs/114-system-console-control-tower/quickstart.md` (adjust if needed)
|
||||
- [X] T044 [P] Optional performance follow-up: add indexes for windowed queries in `database/migrations/` (only if needed after measuring/explain plans; deferred for now because current EXPLAIN baselines do not indicate index pressure at present data volumes)
|
||||
- [X] T045 [P] Performance validation: capture a baseline for the primary list pages (dashboard widgets, `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck`, directory lists) and only then decide whether T044 is needed
|
||||
- [X] T046 Confirm Runbook navigation/shortcuts satisfy FR-007: System navigation provides Runbooks entry, and the canonical run detail exposes a “Go to runbooks” affordance (or explicitly documents “coming soon” where applicable)
|
||||
- [X] T047 Explicitly document v1 scope decisions in tasks acceptance notes: Export (FR-009) is deferred; raw error/context drilldowns (SR-004) are not present in v1
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Enterprise UI Polish
|
||||
|
||||
**Goal**: Elevate the System Console from functional to enterprise-grade: richer page content, contextual badges in navigation, visual hierarchy for break-glass actions, and visible audit trails.
|
||||
|
||||
- [X] T048 [P] [Polish] Add stats overview widget to Recovery > Repair Workspace Owners page (`app/Filament/System/Pages/Ops/RepairWorkspaceOwners.php`): show "X healthy | Y ownerless | Z stuck" counts above the purpose box
|
||||
- [X] T049 [P] [Polish] Add a Workspaces table to Repair Workspace Owners page listing workspaces with owner status (name, owner count, last activity, health badge) — currently the page is empty below the purpose box
|
||||
- [X] T050 [Polish] Restyle the "Assign owner (break-glass)" button: use `->icon('heroicon-o-shield-exclamation')` + `->color('danger')` with better label "Emergency: Assign Owner" to distinguish intentional danger-action from error-state appearance
|
||||
- [X] T051 [P] [Polish] Add navigation badge counts to Ops sidebar items (Failures, Stuck) showing live counts (e.g. "3" next to Failures, "1" next to Stuck) using `::getNavigationBadge()` + `::getNavigationBadgeColor()`
|
||||
- [X] T052 [P] [Polish] Add navigation badge to Recovery > Repair Workspace Owners showing count of ownerless workspaces
|
||||
- [X] T053 [Polish] Add "Recent break-glass actions" infolist/table to the Repair Workspace Owners page showing the last 10 audit log entries for `platform.break_glass.*` actions (who, when, what workspace)
|
||||
- [X] T054 [P] [Polish] Add a System Console health summary widget to the Dashboard (`app/Filament/System/Pages/Dashboard.php`) showing traffic-light indicator (green/yellow/red) based on failure + stuck counts
|
||||
- [X] T055 Run Pint on touched files via `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T056 Run Spec 114 focused tests via `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P1[Phase 1: Setup] --> P2[Phase 2: Foundational]
|
||||
P2 --> US1[Phase 3: US1 (MVP)]
|
||||
P2 --> US2[Phase 4: US2]
|
||||
P2 --> US3[Phase 5: US3]
|
||||
US1 --> POL[Phase 6: Polish]
|
||||
US2 --> POL
|
||||
US3 --> POL
|
||||
POL --> ENT[Phase 7: Enterprise UI Polish]
|
||||
```
|
||||
|
||||
- Phase 2 blocks all user stories.
|
||||
- US2 and US3 can proceed in parallel after Phase 2, but MVP should ship US1 first.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### User Story 1
|
||||
|
||||
- Parallel: T011 + T012 (tests)
|
||||
- Parallel: T014 + T015 + T016 (widgets)
|
||||
|
||||
### User Story 2
|
||||
|
||||
- Parallel: T021 + T022 (tests)
|
||||
- Parallel: T024 + T025 (badge semantics)
|
||||
|
||||
### User Story 3
|
||||
|
||||
- Parallel: T031 + T032 + T033 + T034 (tests)
|
||||
- Parallel: T035 + T036 + T037 (page scaffolds)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1 only)
|
||||
|
||||
1) Complete Phase 1 + Phase 2
|
||||
2) Ship US1 (dashboard widgets + global runs + canonical run detail)
|
||||
3) Add US2 directory
|
||||
4) Add US3 triage pages/actions + access logs
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Notes (v1 Scope)
|
||||
|
||||
- FR-009 Export is explicitly deferred for v1.
|
||||
- SR-004 raw error/context drilldowns are intentionally not exposed in v1 run detail views.
|
||||
37
tests/Feature/Badges/SystemHealthBadgeSemanticsTest.php
Normal file
37
tests/Feature/Badges/SystemHealthBadgeSemanticsTest.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('maps system health ok to an OK success badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::SystemHealth, 'ok');
|
||||
|
||||
expect($spec->label)->toBe('OK');
|
||||
expect($spec->color)->toBe('success');
|
||||
expect($spec->icon)->toBe('heroicon-m-check-circle');
|
||||
});
|
||||
|
||||
it('maps system health warn to a Warn warning badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::SystemHealth, 'warn');
|
||||
|
||||
expect($spec->label)->toBe('Warn');
|
||||
expect($spec->color)->toBe('warning');
|
||||
expect($spec->icon)->toBe('heroicon-m-exclamation-triangle');
|
||||
});
|
||||
|
||||
it('maps system health critical to a Critical danger badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::SystemHealth, 'critical');
|
||||
|
||||
expect($spec->label)->toBe('Critical');
|
||||
expect($spec->color)->toBe('danger');
|
||||
expect($spec->icon)->toBe('heroicon-m-x-circle');
|
||||
});
|
||||
|
||||
it('maps unknown system health states to an Unknown badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::SystemHealth, 'not-a-state');
|
||||
|
||||
expect($spec->label)->toBe('Unknown');
|
||||
expect($spec->color)->toBe('gray');
|
||||
});
|
||||
@ -9,30 +9,38 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns 404 when a tenant session accesses the system panel', function () {
|
||||
it('returns 404 when a tenant session accesses system panel routes', function (string $url) {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get('/system/login')->assertNotFound();
|
||||
$this->actingAs($user)->get($url)->assertNotFound();
|
||||
})->with([
|
||||
'/system/login',
|
||||
'/system',
|
||||
'/system/ops/runbooks',
|
||||
'/system/ops/runs',
|
||||
]);
|
||||
|
||||
// Filament may switch the active guard within the test process,
|
||||
// so ensure the tenant session is set for each request we assert.
|
||||
$this->actingAs($user)->get('/system')->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 when a platform user lacks the required capability', function () {
|
||||
it('returns 403 when a platform user lacks the required capability on system pages', function (string $url) {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system')
|
||||
->get($url)
|
||||
->assertForbidden();
|
||||
});
|
||||
})->with([
|
||||
'/system',
|
||||
'/system/ops/runbooks',
|
||||
'/system/ops/runs',
|
||||
]);
|
||||
|
||||
it('returns 200 when a platform user has the required capability', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [PlatformCapabilities::ACCESS_SYSTEM_PANEL],
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
@ -40,4 +48,3 @@
|
||||
->get('/system')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
|
||||
70
tests/Feature/System/Spec114/AccessLogsTest.php
Normal file
70
tests/Feature/System/Spec114/AccessLogsTest.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('lists platform auth access logs with success and failure statuses plus break-glass actions', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'external_id' => 'platform',
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'action' => 'platform.auth.login',
|
||||
'status' => 'success',
|
||||
'metadata' => ['attempted_email' => 'operator@tenantpilot.io'],
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'action' => 'platform.auth.login',
|
||||
'status' => 'failure',
|
||||
'metadata' => ['attempted_email' => 'operator@tenantpilot.io'],
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'action' => 'platform.break_glass.enter',
|
||||
'status' => 'success',
|
||||
'metadata' => ['reason' => 'Recovery'],
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'action' => 'platform.unrelated.event',
|
||||
'status' => 'success',
|
||||
'metadata' => [],
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/security/access-logs')
|
||||
->assertSuccessful()
|
||||
->assertSee('platform.auth.login')
|
||||
->assertSee('success')
|
||||
->assertSee('failure')
|
||||
->assertSee('platform.break_glass.enter')
|
||||
->assertDontSee('platform.unrelated.event');
|
||||
});
|
||||
58
tests/Feature/System/Spec114/CanonicalRunDetailTest.php
Normal file
58
tests/Feature/System/Spec114/CanonicalRunDetailTest.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('allows canonical run detail for non-runbook operation types with operations view capability', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemOperationRunLinks::view($run))
|
||||
->assertSuccessful()
|
||||
->assertSee('Run #'.(int) $run->getKey());
|
||||
});
|
||||
|
||||
it('does not render raw context payloads in canonical run detail', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'type' => 'inventory_sync',
|
||||
'context' => [
|
||||
'secret_token' => 'top-secret-token',
|
||||
'raw_error' => 'sensitive stack trace',
|
||||
],
|
||||
'failure_summary' => [
|
||||
['code' => 'operation.failed', 'message' => 'Job failed'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemOperationRunLinks::view($run))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('Context (raw)')
|
||||
->assertDontSee('top-secret-token')
|
||||
->assertDontSee('sensitive stack trace');
|
||||
});
|
||||
52
tests/Feature/System/Spec114/ControlTowerDashboardTest.php
Normal file
52
tests/Feature/System/Spec114/ControlTowerDashboardTest.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Dashboard;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
});
|
||||
|
||||
it('forbids system dashboard when platform.console.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('defaults dashboard to the 24h window and allows switching window', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system')
|
||||
->assertSuccessful();
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSet('window', SystemConsoleWindow::LastDay)
|
||||
->callAction('set_window', data: [
|
||||
'window' => SystemConsoleWindow::LastWeek,
|
||||
])
|
||||
->assertSet('window', SystemConsoleWindow::LastWeek);
|
||||
});
|
||||
54
tests/Feature/System/Spec114/DirectoryTenantsTest.php
Normal file
54
tests/Feature/System/Spec114/DirectoryTenantsTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('forbids tenants directory when platform.directory.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/directory/tenants')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('lists tenants in the system directory', function () {
|
||||
$workspace = Workspace::factory()->create(['name' => 'Directory Workspace']);
|
||||
|
||||
Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Contoso',
|
||||
'status' => Tenant::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Fabrikam',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/directory/tenants')
|
||||
->assertSuccessful()
|
||||
->assertSee('Contoso')
|
||||
->assertSee('Fabrikam');
|
||||
});
|
||||
53
tests/Feature/System/Spec114/DirectoryWorkspacesTest.php
Normal file
53
tests/Feature/System/Spec114/DirectoryWorkspacesTest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('forbids workspaces directory when platform.directory.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/directory/workspaces')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('lists workspaces in the system directory', function () {
|
||||
$workspaceA = Workspace::factory()->create(['name' => 'Alpha Workspace']);
|
||||
$workspaceB = Workspace::factory()->create(['name' => 'Bravo Workspace']);
|
||||
|
||||
Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspaceA->getKey(),
|
||||
'name' => 'Tenant A',
|
||||
]);
|
||||
|
||||
Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspaceB->getKey(),
|
||||
'name' => 'Tenant B',
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/directory/workspaces')
|
||||
->assertSuccessful()
|
||||
->assertSee('Alpha Workspace')
|
||||
->assertSee('Bravo Workspace');
|
||||
});
|
||||
54
tests/Feature/System/Spec114/OpsFailuresViewTest.php
Normal file
54
tests/Feature/System/Spec114/OpsFailuresViewTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('forbids failures page when platform.operations.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/ops/failures')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('prefilters failures page to failed runs', function () {
|
||||
$failedRun = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$succeededRun = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/ops/failures')
|
||||
->assertSuccessful()
|
||||
->assertSee(SystemOperationRunLinks::view($failedRun))
|
||||
->assertDontSee(SystemOperationRunLinks::view($succeededRun));
|
||||
});
|
||||
73
tests/Feature/System/Spec114/OpsStuckViewTest.php
Normal file
73
tests/Feature/System/Spec114/OpsStuckViewTest.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function () {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('forbids stuck page when platform.operations.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/ops/stuck')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('shows only queued/running runs that cross stuck thresholds', function () {
|
||||
config()->set('tenantpilot.system_console.stuck_thresholds.queued_minutes', 10);
|
||||
config()->set('tenantpilot.system_console.stuck_thresholds.running_minutes', 20);
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-27 10:00:00'));
|
||||
|
||||
$stuckQueued = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(30),
|
||||
'started_at' => null,
|
||||
]);
|
||||
|
||||
$stuckRunning = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(25),
|
||||
'started_at' => now()->subMinutes(21),
|
||||
]);
|
||||
|
||||
$freshQueued = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(5),
|
||||
'started_at' => null,
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/ops/stuck')
|
||||
->assertSuccessful()
|
||||
->assertSee('#'.(int) $stuckQueued->getKey())
|
||||
->assertSee('#'.(int) $stuckRunning->getKey())
|
||||
->assertDontSee('#'.(int) $freshQueued->getKey());
|
||||
});
|
||||
104
tests/Feature/System/Spec114/OpsTriageActionsTest.php
Normal file
104
tests/Feature/System/Spec114/OpsTriageActionsTest.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Ops\Runs;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'external_id' => 'platform',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides triage actions for operators without platform.operations.manage', function () {
|
||||
$run = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$viewOnlyUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($viewOnlyUser, 'platform');
|
||||
|
||||
Livewire::test(Runs::class)
|
||||
->assertTableActionHidden('retry', $run)
|
||||
->assertTableActionHidden('cancel', $run)
|
||||
->assertTableActionHidden('mark_investigated', $run);
|
||||
});
|
||||
|
||||
it('allows manage operators to run triage actions with audit logs and queued-run ux contract', function () {
|
||||
NotificationFacade::fake();
|
||||
|
||||
$failedRun = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$manageUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
PlatformCapabilities::OPERATIONS_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($manageUser, 'platform');
|
||||
|
||||
Livewire::test(Runs::class)
|
||||
->callTableAction('retry', $failedRun)
|
||||
->assertHasNoTableActionErrors()
|
||||
->assertNotified('Inventory sync queued');
|
||||
|
||||
NotificationFacade::assertNothingSent();
|
||||
expect(DatabaseNotification::query()->count())->toBe(0);
|
||||
|
||||
$retriedRun = OperationRun::query()
|
||||
->whereKeyNot((int) $failedRun->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($retriedRun)->not->toBeNull();
|
||||
expect((string) $retriedRun?->status)->toBe(OperationRunStatus::Queued->value);
|
||||
expect((int) data_get($retriedRun?->context, 'triage.retry_of_run_id'))->toBe((int) $failedRun->getKey());
|
||||
|
||||
$this->get(SystemOperationRunLinks::view($retriedRun))
|
||||
->assertSuccessful()
|
||||
->assertSee('Run #'.(int) $retriedRun?->getKey());
|
||||
|
||||
Livewire::test(Runs::class)
|
||||
->callTableAction('mark_investigated', $failedRun, data: [
|
||||
'reason' => 'Checked by platform operations',
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
expect(AuditLog::query()->where('action', 'platform.system_console.retry')->exists())->toBeTrue();
|
||||
expect(AuditLog::query()->where('action', 'platform.system_console.mark_investigated')->exists())->toBeTrue();
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns 404 for tenant-guard access to representative /system urls', function (string $url) {
|
||||
$tenantUser = User::factory()->create();
|
||||
|
||||
$this->actingAs($tenantUser)->get($url)->assertNotFound();
|
||||
})->with([
|
||||
'/system/login',
|
||||
'/system',
|
||||
'/system/ops/runbooks',
|
||||
'/system/ops/runs',
|
||||
]);
|
||||
|
||||
it('returns 403 for platform users missing required system page capabilities', function (string $url, array $capabilities) {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => $capabilities,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get($url)
|
||||
->assertForbidden();
|
||||
})->with([
|
||||
['/system', []],
|
||||
['/system/ops/runbooks', [PlatformCapabilities::ACCESS_SYSTEM_PANEL]],
|
||||
['/system/ops/runs', [PlatformCapabilities::ACCESS_SYSTEM_PANEL]],
|
||||
]);
|
||||
|
||||
it('uses a distinct session cookie name for /system versus /admin', function () {
|
||||
$systemCookieName = Str::slug((string) config('app.name', 'laravel')).'-system-session';
|
||||
$adminCookieName = (string) config('session.cookie');
|
||||
|
||||
expect($systemCookieName)->not->toBe($adminCookieName);
|
||||
|
||||
$this->get('/system/login')
|
||||
->assertSuccessful()
|
||||
->assertCookie($systemCookieName);
|
||||
|
||||
$this->get('/admin/login')->assertSuccessful();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user