Spec 078: Operations tenantless canonical detail (#95)
Implements Spec 078 operations tenantless canonical migration.
Highlights:
- Canonical run detail at `/admin/operations/{run}` renders with standard Filament chrome + sidebar and reuses `OperationRunResource::infolist()` (schema-based, Filament v5).
- Legacy tenant-scoped resource pages removed; legacy URLs return 404 as required.
- Added full spec test pack under `tests/Feature/078/` and updated existing tests.
- Added safe refresh/header actions wiring and KPI header guard when tenant context is null.
Validation:
- `vendor/bin/sail artisan test --compact tests/Feature/078/` (pass)
- `vendor/bin/sail bin pint --dirty` (pass)
Notes:
- Livewire v4+ compliant (Filament v5).
- Panel providers remain registered in `bootstrap/providers.php` (Laravel 11+ standard).
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #95
This commit is contained in:
parent
fb1046c97a
commit
d56ba85755
@ -7,9 +7,12 @@ Thumbs.db
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
|
*.log*
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
Dockerfile*
|
||||||
|
.dockerignore
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
public/build/
|
public/build/
|
||||||
|
|||||||
6
.github/agents/copilot-instructions.md
vendored
6
.github/agents/copilot-instructions.md
vendored
@ -16,6 +16,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||||
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
- PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x (073-unified-managed-tenant-onboarding-wizard)
|
||||||
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
- PostgreSQL (Sail) + SQLite in tests where applicable (073-unified-managed-tenant-onboarding-wizard)
|
||||||
|
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based) (078-operations-tenantless-canonical)
|
||||||
|
- PostgreSQL (no new migrations — read-only model changes) (078-operations-tenantless-canonical)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -35,9 +37,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
|
||||||
|
- 078-operations-tenantless-canonical: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||||
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
|
- 073-unified-managed-tenant-onboarding-wizard: Added PHP 8.4.x (Composer constraint: `^8.2`) + Laravel 12, Filament 5, Livewire 4+, Pest 4, Sail 1.x
|
||||||
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
|
||||||
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,6 +6,7 @@
|
|||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
*.cache
|
||||||
/.fleet
|
/.fleet
|
||||||
/.idea
|
/.idea
|
||||||
/.nova
|
/.nova
|
||||||
@ -24,6 +25,7 @@ coverage/
|
|||||||
/storage/pail
|
/storage/pail
|
||||||
/storage/framework
|
/storage/framework
|
||||||
/storage/logs
|
/storage/logs
|
||||||
|
/storage/debugbar
|
||||||
/vendor
|
/vendor
|
||||||
/bootstrap/cache
|
/bootstrap/cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
|
|||||||
@ -4,18 +4,22 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages\Operations;
|
namespace App\Filament\Pages\Operations;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class TenantlessOperationRunViewer extends Page
|
class TenantlessOperationRunViewer extends Page
|
||||||
{
|
{
|
||||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
protected static bool $isDiscovered = false;
|
||||||
@ -26,8 +30,10 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
|
|
||||||
public OperationRun $run;
|
public OperationRun $run;
|
||||||
|
|
||||||
|
public bool $opsUxIsTabHidden = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action>
|
* @return array<Action|ActionGroup>
|
||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
@ -36,32 +42,39 @@ protected function getHeaderActions(): array
|
|||||||
->label('Refresh')
|
->label('Refresh')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(fn (): string => url()->current()),
|
->url(fn (): string => isset($this->run)
|
||||||
|
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
||||||
|
: route('admin.operations.index')),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
|
||||||
return $actions;
|
$tenant = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
$related = OperationRunLinks::related($this->run, $tenant);
|
||||||
return $actions;
|
|
||||||
|
$relatedActions = [];
|
||||||
|
|
||||||
|
foreach ($related as $label => $url) {
|
||||||
|
$relatedActions[] = Action::make(Str::slug((string) $label, '_'))
|
||||||
|
->label((string) $label)
|
||||||
|
->url((string) $url)
|
||||||
|
->openUrlInNewTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
$actions[] = Action::make('admin_details')
|
if ($relatedActions !== []) {
|
||||||
->label('Admin details')
|
$actions[] = ActionGroup::make($relatedActions)
|
||||||
|
->label('Open')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray');
|
||||||
->url(fn (): string => route('filament.admin.resources.operations.view', [
|
}
|
||||||
'tenant' => (int) $tenant->getKey(),
|
|
||||||
'record' => (int) $this->run->getKey(),
|
|
||||||
]));
|
|
||||||
|
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
@ -91,4 +104,23 @@ public function mount(OperationRun $run): void
|
|||||||
|
|
||||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return OperationRunResource::infolist($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultInfolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->record($this->run)
|
||||||
|
->columns(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->schema([
|
||||||
|
EmbeddedSchema::make('infolist'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
use App\Filament\Support\VerificationReportChangeIndicator;
|
use App\Filament\Support\VerificationReportChangeIndicator;
|
||||||
use App\Filament\Support\VerificationReportViewer;
|
use App\Filament\Support\VerificationReportViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -91,6 +90,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
||||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
TextEntry::make('target_scope_empty_state')
|
||||||
|
->label('Target')
|
||||||
|
->getStateUsing(static fn (): string => 'No target scope details were recorded for this run.')
|
||||||
|
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) === null)
|
||||||
|
->columnSpanFull(),
|
||||||
TextEntry::make('elapsed')
|
TextEntry::make('elapsed')
|
||||||
->label('Elapsed')
|
->label('Elapsed')
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
||||||
@ -385,6 +389,7 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
|
->label('View run')
|
||||||
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
@ -392,10 +397,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [];
|
||||||
'index' => Pages\ListOperationRuns::route('/'),
|
|
||||||
'view' => Pages\ViewOperationRun::route('/r/{record}'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function targetScopeDisplay(OperationRun $record): ?string
|
private static function targetScopeDisplay(OperationRun $record): ?string
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class ListOperationRuns extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = OperationRunResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
OperationsKpiHeader::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, Tab>
|
|
||||||
*/
|
|
||||||
public function getTabs(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'all' => Tab::make(),
|
|
||||||
'active' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])),
|
|
||||||
'succeeded' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value)),
|
|
||||||
'partial' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::PartiallySucceeded->value)),
|
|
||||||
'failed' => Tab::make()
|
|
||||||
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Failed->value)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getTablePollingInterval(): ?string
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OperationRunResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class ViewOperationRun extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = OperationRunResource::class;
|
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRun $run */
|
|
||||||
$run = $this->getRecord();
|
|
||||||
|
|
||||||
$related = OperationRunLinks::related($run, $tenant);
|
|
||||||
|
|
||||||
$actions = [];
|
|
||||||
|
|
||||||
foreach ($related as $label => $url) {
|
|
||||||
$actions[] = Actions\Action::make(Str::slug($label, '_'))
|
|
||||||
->label($label)
|
|
||||||
->url($url)
|
|
||||||
->openUrlInNewTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($actions)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
Actions\ActionGroup::make($actions)
|
|
||||||
->label('Open')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -40,12 +40,7 @@ protected function getStats(): array
|
|||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return [
|
return [];
|
||||||
Stat::make('Total Runs (30 days)', 0),
|
|
||||||
Stat::make('Active Runs', 0),
|
|
||||||
Stat::make('Failed/Partial (7 days)', 0),
|
|
||||||
Stat::make('Avg Duration (7 days)', '—'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Monitoring;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
|
||||||
use Filament\Forms\Contracts\HasForms;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class OperationsDetail extends Component implements HasForms
|
|
||||||
{
|
|
||||||
use InteractsWithForms;
|
|
||||||
|
|
||||||
public OperationRun $run;
|
|
||||||
|
|
||||||
public function mount(OperationRun $run): void
|
|
||||||
{
|
|
||||||
// Ensure tenant scope
|
|
||||||
abort_unless($run->tenant_id === filament()->getTenant()->id, 403);
|
|
||||||
$this->run = $run;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('livewire.monitoring.operations-detail');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
final class OperationRunLinks
|
final class OperationRunLinks
|
||||||
{
|
{
|
||||||
public static function index(Tenant $tenant): string
|
public static function index(?Tenant $tenant = null): string
|
||||||
{
|
{
|
||||||
return route('admin.operations.index');
|
return route('admin.operations.index');
|
||||||
}
|
}
|
||||||
@ -35,7 +35,7 @@ public static function view(OperationRun|int $run, Tenant $tenant): string
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
public static function related(OperationRun $run, Tenant $tenant): array
|
public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||||
{
|
{
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
|
||||||
@ -43,6 +43,10 @@ public static function related(OperationRun $run, Tenant $tenant): array
|
|||||||
|
|
||||||
$links['Operations'] = self::index($tenant);
|
$links['Operations'] = self::index($tenant);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
|
|
||||||
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||||
|
|
||||||
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
if (is_numeric($providerConnectionId) && class_exists(ProviderConnectionResource::class)) {
|
||||||
|
|||||||
@ -1,137 +1,3 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@php
|
{{ $this->infolist }}
|
||||||
$context = is_array($this->run->context ?? null) ? $this->run->context : [];
|
|
||||||
$targetScope = $context['target_scope'] ?? [];
|
|
||||||
$targetScope = is_array($targetScope) ? $targetScope : [];
|
|
||||||
|
|
||||||
$failures = is_array($this->run->failure_summary ?? null) ? $this->run->failure_summary : [];
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<x-filament::section heading="Summary">
|
|
||||||
<div class="grid grid-cols-1 gap-3 text-sm text-gray-700 dark:text-gray-200 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (int) $this->run->getKey() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Workspace:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) ($this->run->workspace?->name ?? '—') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Operation:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->type }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Initiator:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->initiator_name }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Status:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->status }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Outcome:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ (string) $this->run->outcome }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Started:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->run->started_at?->format('Y-m-d H:i') ?? '—' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Completed:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $this->run->completed_at?->format('Y-m-d H:i') ?? '—' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
<x-filament::section heading="Target scope" :collapsed="false">
|
|
||||||
@php
|
|
||||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
|
||||||
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
|
||||||
|
|
||||||
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
|
|
||||||
$entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($entraTenantId === null && $entraTenantName === null)
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
No target scope details were recorded for this run.
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="flex flex-col gap-2 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
@if ($entraTenantName !== null)
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantName }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($entraTenantId !== null)
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Entra tenant ID:</span>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
<x-filament::section heading="Report">
|
|
||||||
@if ((string) $this->run->status !== 'completed')
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Report unavailable while the run is in progress. Use “Refresh” to re-check stored status.
|
|
||||||
</div>
|
|
||||||
@elseif ((string) $this->run->outcome === 'succeeded')
|
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
No failures were reported.
|
|
||||||
</div>
|
|
||||||
@elseif ($failures === [])
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Report unavailable. The run completed, but no failure details were recorded.
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
Findings
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
@foreach ($failures as $failure)
|
|
||||||
@php
|
|
||||||
$reasonCode = is_array($failure) ? ($failure['reason_code'] ?? null) : null;
|
|
||||||
$message = is_array($failure) ? ($failure['message'] ?? null) : null;
|
|
||||||
|
|
||||||
$reasonCode = is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null;
|
|
||||||
$message = is_string($message) && $message !== '' ? $message : null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if ($reasonCode !== null || $message !== null)
|
|
||||||
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
|
|
||||||
@if ($reasonCode !== null)
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $reasonCode }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@if ($message !== null)
|
|
||||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
{{ $message }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</li>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-filament::section>
|
|
||||||
</div>
|
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
<div class="space-y-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="bg-white p-4 rounded shadow">
|
|
||||||
<h3 class="font-bold text-lg mb-2">Summary</h3>
|
|
||||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
|
|
||||||
<dt class="text-gray-600">Type:</dt>
|
|
||||||
<dd>{{ $run->type }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Status:</dt>
|
|
||||||
<dd>{{ $run->status }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Outcome:</dt>
|
|
||||||
<dd>{{ $run->outcome }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Initiator:</dt>
|
|
||||||
<dd>{{ $run->initiator_name }}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white p-4 rounded shadow">
|
|
||||||
<h3 class="font-bold text-lg mb-2">Timing</h3>
|
|
||||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
|
|
||||||
<dt class="text-gray-600">Created:</dt>
|
|
||||||
<dd>{{ $run->created_at }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Started:</dt>
|
|
||||||
<dd>{{ $run->started_at ?? '-' }}</dd>
|
|
||||||
|
|
||||||
<dt class="text-gray-600">Completed:</dt>
|
|
||||||
<dd>{{ $run->completed_at ?? '-' }}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if(!empty($run->summary_counts))
|
|
||||||
<div class="bg-white p-4 rounded shadow">
|
|
||||||
<h3 class="font-bold text-lg mb-2">Counts</h3>
|
|
||||||
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->summary_counts, JSON_PRETTY_PRINT) }}</pre>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(!empty($run->failure_summary))
|
|
||||||
<div class="bg-white p-4 rounded shadow border-l-4 border-red-500">
|
|
||||||
<h3 class="font-bold text-lg mb-2 text-red-700">Failures</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
@foreach($run->failure_summary as $failure)
|
|
||||||
<div class="bg-red-50 p-2 rounded">
|
|
||||||
<div class="font-mono text-xs text-red-800">{{ $failure['code'] ?? 'UNKNOWN' }}</div>
|
|
||||||
<div class="text-sm text-red-900">{{ $failure['message'] ?? 'Unknown error' }}</div>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="bg-white p-4 rounded shadow">
|
|
||||||
<h3 class="font-bold text-lg mb-2">Context</h3>
|
|
||||||
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->context, JSON_PRETTY_PRINT) }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -137,6 +137,20 @@
|
|||||||
->get('/admin/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
->get('/admin/onboarding', \App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class)
|
||||||
->name('admin.onboarding');
|
->name('admin.onboarding');
|
||||||
|
|
||||||
|
Route::middleware([
|
||||||
|
'web',
|
||||||
|
'panel:admin',
|
||||||
|
'ensure-correct-guard:web',
|
||||||
|
DenyNonMemberTenantAccess::class,
|
||||||
|
DisableBladeIconComponents::class,
|
||||||
|
DispatchServingFilamentEvent::class,
|
||||||
|
FilamentAuthenticate::class,
|
||||||
|
'ensure-workspace-selected',
|
||||||
|
'ensure-filament-tenant-selected',
|
||||||
|
])
|
||||||
|
->get('/admin/t/{tenant}/operations', fn () => redirect()->route('admin.operations.index'))
|
||||||
|
->name('admin.operations.legacy-index');
|
||||||
|
|
||||||
Route::middleware([
|
Route::middleware([
|
||||||
'web',
|
'web',
|
||||||
'panel:admin',
|
'panel:admin',
|
||||||
@ -187,6 +201,7 @@
|
|||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
FilamentAuthenticate::class,
|
FilamentAuthenticate::class,
|
||||||
|
'ensure-filament-tenant-selected',
|
||||||
])
|
])
|
||||||
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
||||||
->name('admin.operations.view');
|
->name('admin.operations.view');
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-02-06
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs) — Implementation Notes section is clearly marked non-normative; FR-078-002 mentions trait names as implementation guidance only
|
||||||
|
- [x] Focused on user value and business needs — all user stories describe user outcomes, not system internals
|
||||||
|
- [x] Written for non-technical stakeholders — principles and requirements use domain language
|
||||||
|
- [x] All mandatory sections completed — User Scenarios, Requirements, Success Criteria all present
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain — all decisions resolved (302 vs 301, KPI deferral, infolist approach)
|
||||||
|
- [x] Requirements are testable and unambiguous — each FR has specific verifiable behavior
|
||||||
|
- [x] Success criteria are measurable — SC-001 through SC-006 all have concrete pass/fail conditions
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details) — criteria reference URLs and user outcomes, not code
|
||||||
|
- [x] All acceptance scenarios are defined — 4 user stories with given/when/then scenarios
|
||||||
|
- [x] Edge cases are identified — 5 edge cases documented including null workspace, non-numeric record, null tenant
|
||||||
|
- [x] Scope is clearly bounded — Non-Goals section explicitly excludes KPI workspace-scoping, alerts engine, capability-gating
|
||||||
|
- [x] Dependencies and assumptions identified — baseline routes, existing link helpers, constitution alignment documented
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria — FR-078-001 through FR-078-012 each specify observable behavior
|
||||||
|
- [x] User scenarios cover primary flows — canonical view, legacy redirects, contextual nav, list regression
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria — SC-001 (one canonical URL), SC-003 (secure redirects), SC-005 (verification report tenantless)
|
||||||
|
- [x] No implementation details leak into specification — Implementation Notes section is non-normative; core spec is behavior-focused
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- FR-078-002 includes implementation guidance (trait names) as a non-normative hint for planners; the normative requirement is "reuse infolist schema" regardless of approach.
|
||||||
|
- Open decision on 301 vs 302 documented; 302 chosen as Phase 1 default with clear promotion path.
|
||||||
|
- KPI workspace-scoping explicitly deferred (Non-Goals + FR-078-008) — keeps migration scope focused.
|
||||||
109
specs/078-operations-tenantless-canonical/contracts/routes.md
Normal file
109
specs/078-operations-tenantless-canonical/contracts/routes.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# Route Contracts: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature**: 078-operations-tenantless-canonical
|
||||||
|
**Date**: 2026-02-06
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canonical Routes (Retained — No Changes)
|
||||||
|
|
||||||
|
### GET /admin/operations
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Route name** | `admin.operations.index` |
|
||||||
|
| **Handler** | `App\Filament\Pages\Monitoring\Operations` |
|
||||||
|
| **Middleware** | `web`, `panel:admin`, `ensure-correct-guard:web`, `DenyNonMemberTenantAccess`, Filament middleware, `ensure-workspace-selected`, `ensure-filament-tenant-selected` |
|
||||||
|
| **Auth** | Requires authentication + workspace membership |
|
||||||
|
| **Scope** | Workspace-level (shows all runs in workspace) |
|
||||||
|
| **Response** | 200 HTML (Livewire page) |
|
||||||
|
|
||||||
|
### GET /admin/operations/{run}
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Route name** | `admin.operations.view` |
|
||||||
|
| **Handler** | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` |
|
||||||
|
| **Middleware** | `web`, `panel:admin`, `ensure-correct-guard:web`, `DenyNonMemberTenantAccess`, Filament middleware |
|
||||||
|
| **Auth** | Requires authentication + workspace membership for `$run->workspace_id` |
|
||||||
|
| **Model binding** | `{run}` resolves to `OperationRun` by ID |
|
||||||
|
| **Non-member** | 404 (deny-as-not-found) |
|
||||||
|
| **Not found** | 404 (Laravel model binding) |
|
||||||
|
| **Response** | 200 HTML (Livewire page with infolist) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decommissioned Routes (Resource-Generated Routes Removed After Migration)
|
||||||
|
|
||||||
|
### GET /admin/t/{tenant}/operations/r/{record}
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Route name** | `filament.admin.resources.operations.view` |
|
||||||
|
| **Status** | ❌ **REMOVED** — route no longer registered |
|
||||||
|
| **After migration** | Natural 404 |
|
||||||
|
| **Previously** | `ViewOperationRun` (Filament ViewRecord page) |
|
||||||
|
|
||||||
|
### GET /admin/t/{tenant}/operations
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Route name** | `filament.admin.resources.operations.index` |
|
||||||
|
| **Status** | ❌ **REMOVED** — route no longer registered |
|
||||||
|
| **After migration** | Replaced by explicit convenience route `admin.operations.legacy-index` that redirects 302 → `/admin/operations` |
|
||||||
|
| **Previously** | `ListOperationRuns` (Filament ListRecords page) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Link Generation Contracts
|
||||||
|
|
||||||
|
### OperationRunLinks::view($run, $tenant)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Returns** | `route('admin.operations.view', ['run' => $run])` |
|
||||||
|
| **Delegates to** | `OperationRunLinks::tenantlessView($run)` |
|
||||||
|
| **Tenant parameter** | Ignored (no-op) |
|
||||||
|
| **Change** | None — already canonical |
|
||||||
|
|
||||||
|
### OperationRunLinks::tenantlessView($run)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Returns** | `route('admin.operations.view', ['run' => $run])` |
|
||||||
|
| **Change** | None |
|
||||||
|
|
||||||
|
### OperationRunLinks::index()
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Returns** | `route('admin.operations.index')` |
|
||||||
|
| **Change** | None |
|
||||||
|
|
||||||
|
### OperationRunLinks::related($run, $tenant)
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Returns** | Array of up to 11 contextual link arrays |
|
||||||
|
| **Change** | None — consumed by `TenantlessOperationRunViewer` header actions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Route Assertions
|
||||||
|
|
||||||
|
### Positive (must work)
|
||||||
|
|
||||||
|
| Test | Route | Expected |
|
||||||
|
|------|-------|----------|
|
||||||
|
| T-078-001 | `GET /admin/operations/{run}` | 200 (member) |
|
||||||
|
| T-078-001 | `GET /admin/operations/{run}` | 200 (run with `tenant_id = null`) |
|
||||||
|
| T-078-009 | `GET /admin/t/{tenant}/operations` | 302 redirect to `/admin/operations` |
|
||||||
|
|
||||||
|
### Negative (must 404)
|
||||||
|
|
||||||
|
| Test | Route | Expected |
|
||||||
|
|------|-------|----------|
|
||||||
|
| T-078-001 | `GET /admin/operations/{run}` | 404 (non-member) |
|
||||||
|
| T-078-002 | `GET /admin/t/{tenant}/operations/r/{record}` | 404 (any user) |
|
||||||
|
| T-078-004 | Route name `filament.admin.resources.operations.view` | Not registered |
|
||||||
|
| T-078-004 | Route name `filament.admin.resources.operations.index` | Not registered |
|
||||||
94
specs/078-operations-tenantless-canonical/data-model.md
Normal file
94
specs/078-operations-tenantless-canonical/data-model.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Data Model: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature**: 078-operations-tenantless-canonical
|
||||||
|
**Date**: 2026-02-06
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does **not** introduce new models or migrations. It reorganizes how existing models are rendered and routed.
|
||||||
|
|
||||||
|
## Entities (Existing — No Changes)
|
||||||
|
|
||||||
|
### OperationRun
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `id` | `bigint` (PK) | Auto-increment |
|
||||||
|
| `workspace_id` | `bigint` (FK) | Required. Authorization boundary. |
|
||||||
|
| `tenant_id` | `bigint` (FK, nullable) | Null for workspace-level runs (e.g., onboarding). |
|
||||||
|
| `type` | `string` | Operation type slug (e.g., `policy.sync`, `restore.execute`). |
|
||||||
|
| `status` | `enum` | `OperationRunStatus`: Queued, Running, Completed, Cancelled. |
|
||||||
|
| `outcome` | `enum` (nullable) | `OperationRunOutcome`: Succeeded, PartiallySucceeded, Failed. |
|
||||||
|
| `context` | `jsonb` | Contains `target_scope`, `verification_report`, etc. |
|
||||||
|
| `summary_counts` | `jsonb` | `{total, processed, succeeded, failed, skipped}` |
|
||||||
|
| `failure_summary` | `jsonb` (nullable) | Sanitized failure details. |
|
||||||
|
| `initiated_by` | `bigint` (FK, nullable) | User who started the run. |
|
||||||
|
| `started_at` | `timestamp` (nullable) | |
|
||||||
|
| `completed_at` | `timestamp` (nullable) | |
|
||||||
|
| `created_at` | `timestamp` | |
|
||||||
|
| `updated_at` | `timestamp` | |
|
||||||
|
|
||||||
|
**Relationships**: `belongsTo Workspace`, `belongsTo Tenant` (nullable), `belongsTo User` (initiated_by)
|
||||||
|
|
||||||
|
### WorkspaceMembership (Authorization Boundary)
|
||||||
|
|
||||||
|
The `OperationRunPolicy::view()` checks:
|
||||||
|
1. User must be a member of `$run->workspace_id`
|
||||||
|
2. Returns `Response::denyAsNotFound()` if not a member
|
||||||
|
|
||||||
|
No changes to this model or policy logic.
|
||||||
|
|
||||||
|
## Routing Changes (No Model Impact)
|
||||||
|
|
||||||
|
### Routes Removed
|
||||||
|
|
||||||
|
| Route Name | Pattern | Handler |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| `filament.admin.resources.operations.index` | `GET /admin/t/{tenant}/operations` | `ListOperationRuns` (resource-generated) |
|
||||||
|
| `filament.admin.resources.operations.view` | `GET /admin/t/{tenant}/operations/r/{record}` | `ViewOperationRun` |
|
||||||
|
|
||||||
|
### Route Added
|
||||||
|
|
||||||
|
| Route Name | Pattern | Handler |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| `admin.operations.legacy-index` | `GET /admin/t/{tenant}/operations` | Redirect 302 to `/admin/operations` |
|
||||||
|
|
||||||
|
### Routes Retained (Unchanged)
|
||||||
|
|
||||||
|
| Route Name | Pattern | Handler |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| `admin.operations.index` | `GET /admin/operations` | `Operations.php` |
|
||||||
|
| `admin.operations.view` | `GET /admin/operations/{run}` | `TenantlessOperationRunViewer` |
|
||||||
|
|
||||||
|
### Files Deleted
|
||||||
|
|
||||||
|
| File | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php` | Replaced by TenantlessOperationRunViewer |
|
||||||
|
| `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php` | Replaced by Operations.php |
|
||||||
|
| `app/Livewire/Monitoring/OperationsDetail.php` | Dead code |
|
||||||
|
| `resources/views/livewire/monitoring/operations-detail.blade.php` | Dead code (blade for OperationsDetail) |
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `app/Filament/Resources/OperationRunResource.php` | `getPages()` returns `[]` |
|
||||||
|
| `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Reuse infolist via schema, add related links |
|
||||||
|
| `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` | Replace hand-coded HTML with `{{ $this->infolist }}` |
|
||||||
|
| `app/Filament/Widgets/Operations/OperationsKpiHeader.php` | Hide stats when no tenant context |
|
||||||
|
|
||||||
|
### Test Files Requiring Updates
|
||||||
|
|
||||||
|
| File | Change Required |
|
||||||
|
|------|----------------|
|
||||||
|
| `tests/Feature/Verification/VerificationAuthorizationTest.php` | Replace `OperationRunResource::getUrl('view')` with `route('admin.operations.view')` |
|
||||||
|
| `tests/Feature/OpsUx/FailureSanitizationTest.php` | Replace `OperationRunResource::getUrl('view')` with canonical route; replace `ViewOperationRun` mount |
|
||||||
|
| `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` | Update guard regex to account for headless resource |
|
||||||
|
| `tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` |
|
||||||
|
| `tests/Feature/Verification/VerificationReportRedactionTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` |
|
||||||
|
| `tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php` | Replace `ViewOperationRun` mount with `TenantlessOperationRunViewer` |
|
||||||
|
| `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` | Remove `ListOperationRuns` test; add route-not-registered assertion |
|
||||||
|
| `tests/Feature/Monitoring/OperationsTenantScopeTest.php` | Remove `ListOperationRuns` test |
|
||||||
288
specs/078-operations-tenantless-canonical/plan.md
Normal file
288
specs/078-operations-tenantless-canonical/plan.md
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
# Implementation Plan: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Branch**: `078-operations-tenantless-canonical` | **Date**: 2025-07-13 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/078-operations-tenantless-canonical/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Make Operations detail **fully canonical** at `/admin/operations/{run}` by converting `TenantlessOperationRunViewer` to reuse `OperationRunResource::infolist()` via Filament v5's unified schema system, decommissioning auto-generated tenant-scoped resource pages (they naturally 404 after route removal), cleaning up dead code, and updating all affected tests.
|
||||||
|
|
||||||
|
Key approach: Filament v5 deprecated `InteractsWithInfolists` — every `Page` already has `InteractsWithSchemas`. The existing `OperationRunResource::infolist()` is `public static` with no `$this` references and already handles `Filament::getTenant()` returning null. This means `TenantlessOperationRunViewer` can define `infolist(Schema $schema)` to delegate directly, achieving full visual parity with zero code duplication.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Filament Infolists (schema-based)
|
||||||
|
**Storage**: PostgreSQL (no new migrations — read-only model changes)
|
||||||
|
**Testing**: Pest v4 (Feature tests)
|
||||||
|
**Target Platform**: Web (Laravel Sail / Docker)
|
||||||
|
**Project Type**: Web application (monolith)
|
||||||
|
**Performance Goals**: DB-only rendering (no external calls on page load)
|
||||||
|
**Constraints**: Tenantless pages must render without `Filament::getTenant()`; no new dependencies
|
||||||
|
**Scale/Scope**: ~15 files modified/deleted, ~8 test files updated, 0 new migrations
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: All pass. No violations.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Inventory-first | N/A | No inventory changes |
|
||||||
|
| Read/write separation | Pass | Feature is read-only (rendering changes only) |
|
||||||
|
| Graph contract path | N/A | No Graph calls |
|
||||||
|
| Deterministic capabilities | N/A | No capability changes |
|
||||||
|
| RBAC-UX planes | Pass | Workspace-level auth only; non-member = 404 (RBAC-UX-002) |
|
||||||
|
| RBAC-UX destructive | N/A | No destructive actions |
|
||||||
|
| RBAC-UX global search | Pass | Resource has `$shouldRegisterNavigation = false`, no `$recordTitleAttribute` |
|
||||||
|
| Tenant isolation | Pass | Reads workspace-scoped; no cross-tenant access |
|
||||||
|
| Run observability | N/A | No new operations; monitoring pages remain DB-only |
|
||||||
|
| Automation | N/A | No queued/scheduled work |
|
||||||
|
| Data minimization | Pass | No new data stored |
|
||||||
|
| Badge semantics | Pass | Existing `BadgeRenderer` reused via infolist — no new badge mappings |
|
||||||
|
|
||||||
|
**Post-design re-check**: Same results — no constitution violations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/078-operations-tenantless-canonical/
|
||||||
|
+-- spec.md # Feature specification
|
||||||
|
+-- plan.md # This file
|
||||||
|
+-- research.md # Phase 0: Filament v5 schema research
|
||||||
|
+-- data-model.md # Phase 1: Entity & routing changes
|
||||||
|
+-- quickstart.md # Phase 1: Verification steps
|
||||||
|
+-- contracts/
|
||||||
|
| +-- routes.md # Route contract (before/after)
|
||||||
|
+-- checklists/
|
||||||
|
| +-- requirements.md # Spec quality checklist
|
||||||
|
+-- tasks.md # Phase 2 output (created by /speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (files touched)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
+-- Filament/
|
||||||
|
| +-- Resources/
|
||||||
|
| | +-- OperationRunResource.php # getPages() returns []
|
||||||
|
| | +-- OperationRunResource/Pages/
|
||||||
|
| | +-- ViewOperationRun.php # DELETE
|
||||||
|
| | +-- ListOperationRuns.php # DELETE
|
||||||
|
| +-- Pages/
|
||||||
|
| | +-- Operations/
|
||||||
|
| | +-- TenantlessOperationRunViewer.php # Infolist reuse + related links
|
||||||
|
| +-- Widgets/
|
||||||
|
| +-- Operations/
|
||||||
|
| +-- OperationsKpiHeader.php # Hide stats when no tenant
|
||||||
|
+-- Livewire/
|
||||||
|
+-- Monitoring/
|
||||||
|
+-- OperationsDetail.php # DELETE (dead code)
|
||||||
|
|
||||||
|
resources/views/
|
||||||
|
+-- filament/pages/operations/
|
||||||
|
| +-- tenantless-operation-run-viewer.blade.php # Replace HTML with infolist render
|
||||||
|
+-- livewire/monitoring/
|
||||||
|
+-- operations-detail.blade.php # DELETE (dead code)
|
||||||
|
|
||||||
|
tests/Feature/
|
||||||
|
+-- Operations/
|
||||||
|
| +-- TenantlessOperationRunViewerTest.php # Update infolist assertions
|
||||||
|
+-- Monitoring/
|
||||||
|
| +-- OperationsCanonicalUrlsTest.php # Remove ListOperationRuns, add route-gone
|
||||||
|
| +-- OperationsTenantScopeTest.php # Remove ListOperationRuns reference
|
||||||
|
+-- Verification/
|
||||||
|
| +-- VerificationAuthorizationTest.php # Canonical route instead of getUrl
|
||||||
|
| +-- VerificationReportViewerDbOnlyTest.php # TenantlessViewer replaces ViewOperationRun
|
||||||
|
| +-- VerificationReportRedactionTest.php # TenantlessViewer replaces ViewOperationRun
|
||||||
|
| +-- VerificationReportMissingOrMalformedTest.php # TenantlessViewer replaces ViewOperationRun
|
||||||
|
+-- OpsUx/
|
||||||
|
| +-- FailureSanitizationTest.php # Canonical route + TenantlessViewer
|
||||||
|
| +-- CanonicalViewRunLinksTest.php # Update guard regex
|
||||||
|
+-- 078/ # NEW spec-specific tests
|
||||||
|
+-- CanonicalDetailRenderTest.php
|
||||||
|
+-- LegacyRoutesReturnNotFoundTest.php
|
||||||
|
+-- KpiHeaderTenantlessTest.php
|
||||||
|
+-- VerificationReportTenantlessTest.php
|
||||||
|
+-- TenantListRedirectTest.php
|
||||||
|
+-- RelatedLinksOnDetailTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Standard Laravel monolith. No new directories except `tests/Feature/078/` for spec tests.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No constitution violations to justify.
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| None | N/A | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase A — Headless Resource + Dead Code Cleanup
|
||||||
|
|
||||||
|
**Goal**: Remove auto-generated routes; delete dead code.
|
||||||
|
**Risk**: Low — removing unused pages and dead code.
|
||||||
|
**Tests first**: T-078-004 (routes not registered), T-078-002 (legacy URLs 404).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `OperationRunResource.php` | `getPages()` returns `[]` |
|
||||||
|
| A.2 | `ViewOperationRun.php` | Delete file |
|
||||||
|
| A.3 | `ListOperationRuns.php` | Delete file |
|
||||||
|
| A.4 | `OperationsDetail.php` | Delete file (dead code) |
|
||||||
|
| A.5 | `operations-detail.blade.php` | Delete file (dead code) |
|
||||||
|
| A.6 | `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` | New: T-078-002 + T-078-004 |
|
||||||
|
| A.7 | 7 existing test files | Replace `ViewOperationRun` / `ListOperationRuns` / `getUrl('view')` references |
|
||||||
|
|
||||||
|
**Exit criteria**: All existing tests pass; legacy URLs return 404; no routes registered for resource.
|
||||||
|
|
||||||
|
### Phase B — Infolist Reuse on TenantlessOperationRunViewer
|
||||||
|
|
||||||
|
**Goal**: Replace hand-coded Blade with Filament schema-based infolist for full visual parity.
|
||||||
|
**Risk**: Medium — schema auto-discovery on standalone Page needs verification.
|
||||||
|
**Tests first**: T-078-001 (canonical detail renders), T-078-008 (verification report tenantless).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `TenantlessOperationRunViewer.php` | Add `infolist(Schema $schema)` — delegates to `OperationRunResource::infolist($schema)` |
|
||||||
|
| B.2 | `TenantlessOperationRunViewer.php` | Add `defaultInfolist(Schema $schema)` — `->record($this->run)->columns(2)` |
|
||||||
|
| B.3 | `TenantlessOperationRunViewer.php` | Add `content(Schema $schema)` — returns `EmbeddedSchema::make('infolist')` |
|
||||||
|
| B.4 | `TenantlessOperationRunViewer.php` | Add `public bool $opsUxIsTabHidden = false` property (polling callback) |
|
||||||
|
| B.5 | `tenantless-operation-run-viewer.blade.php` | Replace hand-coded HTML with infolist render tag |
|
||||||
|
| B.6 | `tests/Feature/078/CanonicalDetailRenderTest.php` | New: T-078-001 (+ T-078-007 guard) |
|
||||||
|
| B.7 | `tests/Feature/078/VerificationReportTenantlessTest.php` | New: T-078-008 |
|
||||||
|
|
||||||
|
**Exit criteria**: Canonical detail shows identical layout to old tenant-scoped view; verification report section renders.
|
||||||
|
|
||||||
|
### Phase C — Contextual Navigation (Related Links)
|
||||||
|
|
||||||
|
**Goal**: Replace "Admin details" button with `OperationRunLinks::related()` action group.
|
||||||
|
**Risk**: Low — mechanism already exists, just wiring.
|
||||||
|
**Tests first**: T-078-010 (related links appear), T-078-005 (no "Admin details" link).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `TenantlessOperationRunViewer.php` | Add `getHeaderActions()` using `OperationRunLinks::related()` |
|
||||||
|
| C.2 | `TenantlessOperationRunViewer.php` | Remove "Admin details" button code (~line 61) |
|
||||||
|
| C.3 | `tests/Feature/078/RelatedLinksOnDetailTest.php` | New: T-078-010 + T-078-005 + T-078-012 |
|
||||||
|
|
||||||
|
**Exit criteria**: Related links render for different run types; "Admin details" link absent.
|
||||||
|
|
||||||
|
### Phase D — KPI Header Tenantless Handling
|
||||||
|
|
||||||
|
**Goal**: Hide KPI stats when no tenant context (not workspace-scoped — deferred).
|
||||||
|
**Risk**: Low — conditional early return.
|
||||||
|
**Tests first**: T-078-006 (KPI hidden in tenantless mode), T-078-011 (tenantless list query safety).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `OperationsKpiHeader.php` | `getStats()`: if `Filament::getTenant()` is null then return `[]` |
|
||||||
|
| D.2 | `tests/Feature/078/KpiHeaderTenantlessTest.php` | New: T-078-006 |
|
||||||
|
| D.3 | `tests/Feature/078/OperationsListTenantlessSafetyTest.php` | New: T-078-011 |
|
||||||
|
|
||||||
|
**Exit criteria**: Operations page without tenant context renders without errors; KPI section hidden.
|
||||||
|
|
||||||
|
### Phase E — List Redirect (FR-078-012)
|
||||||
|
|
||||||
|
**Goal**: Convenience redirect for decommissioned list URL.
|
||||||
|
**Risk**: Low — single route addition.
|
||||||
|
**Tests first**: T-078-009 (302 redirect).
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `routes/web.php` | Add redirect: `/admin/t/{tenant}/operations` 302 to `/admin/operations` |
|
||||||
|
| E.2 | `tests/Feature/078/TenantListRedirectTest.php` | New: T-078-009 |
|
||||||
|
|
||||||
|
**Exit criteria**: Tenant-scoped list URL redirects; no other URLs affected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — Schema-based infolist (not InteractsWithInfolists)
|
||||||
|
|
||||||
|
Filament v5 unified forms/infolists/tables into the schema system. `InteractsWithInfolists` is deprecated. Every `Page` already has `InteractsWithSchemas` via `BasePage`. Define `infolist(Schema $schema)` on the page and it auto-discovers via reflection.
|
||||||
|
|
||||||
|
**See**: [research.md](research.md) R-001
|
||||||
|
|
||||||
|
### D-002 — Empty getPages() (not resource exclusion)
|
||||||
|
|
||||||
|
Returning `[]` from `getPages()` cleanly prevents all route registration while keeping the class available for `::table()` and `::infolist()` reuse. Simpler than excluding from panel discovery.
|
||||||
|
|
||||||
|
**See**: [research.md](research.md) R-003
|
||||||
|
|
||||||
|
### D-003 — Natural 404 (not redirect handlers)
|
||||||
|
|
||||||
|
After route decommission, legacy detail URLs naturally 404. No redirect handlers needed — simplest approach, zero information leakage, zero maintenance.
|
||||||
|
|
||||||
|
**See**: spec.md clarifications (FR-078-005/006 removed)
|
||||||
|
|
||||||
|
### D-004 — KPI hidden (not workspace-scoped queries)
|
||||||
|
|
||||||
|
Phase 1 hides KPI when no tenant. Full workspace-scoped KPI requires refactoring 6 queries + `ActiveRuns::existForWorkspace()` — deferred to separate spec.
|
||||||
|
|
||||||
|
**See**: [research.md](research.md) R-004, R-005
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Schema auto-discovery fails on standalone Page | Medium | Low | Research confirms it works (R-001); fallback: static builder extraction |
|
||||||
|
| Test updates miss a reference to deleted pages | Low | Medium | `grep -r` sweep for ViewOperationRun and ListOperationRuns before PR |
|
||||||
|
| Infolist polling breaks without opsUxIsTabHidden | Low | Medium | Add property explicitly; existing poll logic has null-safe fallback |
|
||||||
|
| KPI widget error when tenant is null | Low | Low | Already returns [] on null; this change makes it explicit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
### New Tests (spec-specific in tests/Feature/078/)
|
||||||
|
|
||||||
|
| Test ID | File | Coverage |
|
||||||
|
|---------|------|----------|
|
||||||
|
| T-078-001 | CanonicalDetailRenderTest.php | Detail renders with/without tenant_id |
|
||||||
|
| T-078-002 | LegacyRoutesReturnNotFoundTest.php | Legacy detail URLs return 404 |
|
||||||
|
| T-078-004 | LegacyRoutesReturnNotFoundTest.php | Route names not registered |
|
||||||
|
| T-078-005 | RelatedLinksOnDetailTest.php | No "Admin details" link |
|
||||||
|
| T-078-006 | KpiHeaderTenantlessTest.php | KPI hidden without tenant |
|
||||||
|
| T-078-008 | VerificationReportTenantlessTest.php | Verification report renders tenantless |
|
||||||
|
| T-078-009 | TenantListRedirectTest.php | List redirect 302 |
|
||||||
|
| T-078-010 | RelatedLinksOnDetailTest.php | Related links in header actions |
|
||||||
|
| T-078-011 | OperationsListTenantlessSafetyTest.php | List renders safely with tenant and tenantless context |
|
||||||
|
| T-078-012 | RelatedLinksOnDetailTest.php | Canonical CTA label is "View run" and legacy CTA removed |
|
||||||
|
|
||||||
|
### Updated Tests (existing)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| VerificationAuthorizationTest.php | `getUrl('view')` replaced with `route('admin.operations.view')` |
|
||||||
|
| FailureSanitizationTest.php | `getUrl('view')` replaced with canonical route; ViewOperationRun replaced with TenantlessOperationRunViewer |
|
||||||
|
| CanonicalViewRunLinksTest.php | Update guard regex for headless resource |
|
||||||
|
| VerificationReportViewerDbOnlyTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer |
|
||||||
|
| VerificationReportRedactionTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer |
|
||||||
|
| VerificationReportMissingOrMalformedTest.php | ViewOperationRun replaced with TenantlessOperationRunViewer |
|
||||||
|
| OperationsCanonicalUrlsTest.php | Remove ListOperationRuns test, add route-gone assertion |
|
||||||
|
| OperationsTenantScopeTest.php | Remove ListOperationRuns reference |
|
||||||
|
|
||||||
|
### Focused Test Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact \
|
||||||
|
tests/Feature/078/ \
|
||||||
|
tests/Feature/Operations/TenantlessOperationRunViewerTest.php \
|
||||||
|
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \
|
||||||
|
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
|
||||||
|
tests/Feature/Verification/VerificationAuthorizationTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportRedactionTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php \
|
||||||
|
tests/Feature/OpsUx/FailureSanitizationTest.php \
|
||||||
|
tests/Feature/OpsUx/CanonicalViewRunLinksTest.php
|
||||||
|
```
|
||||||
80
specs/078-operations-tenantless-canonical/quickstart.md
Normal file
80
specs/078-operations-tenantless-canonical/quickstart.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# Quickstart: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature**: 078-operations-tenantless-canonical
|
||||||
|
**Branch**: `078-operations-tenantless-canonical`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Laravel Sail running (`vendor/bin/sail up -d`)
|
||||||
|
- Database migrated (`vendor/bin/sail artisan migrate`)
|
||||||
|
- At least one workspace with a user member
|
||||||
|
- At least one `OperationRun` record (with and without `tenant_id`)
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
### 1. Canonical detail renders
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Visit as authenticated workspace member
|
||||||
|
# URL: /admin/operations/{run_id}
|
||||||
|
# Expected: Full infolist renders (summary, target scope, verification report, counts, context JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Auto-generated tenant routes are gone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan route:list --name=filament.admin.resources.operations
|
||||||
|
# Expected: No routes listed (empty output)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Canonical list still works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Visit: /admin/operations
|
||||||
|
# Expected: Workspace-scoped table with status tabs, filters
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the focused test pack for this spec:
|
||||||
|
vendor/bin/sail artisan test --compact \
|
||||||
|
tests/Feature/078/ \
|
||||||
|
tests/Feature/Operations/TenantlessOperationRunViewerTest.php \
|
||||||
|
tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php \
|
||||||
|
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
|
||||||
|
tests/Feature/Verification/VerificationAuthorizationTest.php \
|
||||||
|
tests/Feature/OpsUx/FailureSanitizationTest.php \
|
||||||
|
tests/Feature/OpsUx/CanonicalViewRunLinksTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportRedactionTest.php \
|
||||||
|
tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php
|
||||||
|
|
||||||
|
# Expected: All pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Pint formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files to Inspect
|
||||||
|
|
||||||
|
| File | What to check |
|
||||||
|
|------|---------------|
|
||||||
|
| `app/Filament/Resources/OperationRunResource.php` | `getPages()` returns `[]` |
|
||||||
|
| `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Uses schema-based infolist, has related links header |
|
||||||
|
| `app/Filament/Widgets/Operations/OperationsKpiHeader.php` | Returns empty stats when no tenant context |
|
||||||
|
| `app/Filament/Pages/Monitoring/Operations.php` | Unchanged — still reuses `OperationRunResource::table()` |
|
||||||
|
|
||||||
|
## What Was Deleted
|
||||||
|
|
||||||
|
| File | Why |
|
||||||
|
|------|-----|
|
||||||
|
| `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php` | Replaced by TenantlessOperationRunViewer |
|
||||||
|
| `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php` | Replaced by Operations.php |
|
||||||
|
| `app/Livewire/Monitoring/OperationsDetail.php` | Dead code |
|
||||||
|
| `resources/views/livewire/monitoring/operations-detail.blade.php` | Dead code |
|
||||||
93
specs/078-operations-tenantless-canonical/research.md
Normal file
93
specs/078-operations-tenantless-canonical/research.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Research: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature**: 078-operations-tenantless-canonical
|
||||||
|
**Date**: 2026-02-06
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-001 — Filament v5 Infolist Reuse on Standalone Pages
|
||||||
|
|
||||||
|
**Decision**: Use the native `InteractsWithSchemas` mechanism (already on every `Page`). No need for `InteractsWithInfolists` or `HasInfolists`.
|
||||||
|
|
||||||
|
**Rationale**: In Filament v5, the schema system is unified — forms, infolists, and tables all go through `InteractsWithSchemas`. The `InteractsWithInfolists` trait is fully deprecated (every method proxies to schema equivalents). `HasInfolists` is an empty marker interface. Every `Filament\Pages\Page` already extends `BasePage` which uses `InteractsWithSchemas` and implements `HasSchemas`.
|
||||||
|
|
||||||
|
**How it works**:
|
||||||
|
1. Define `public function infolist(Schema $schema): Schema` on the Page
|
||||||
|
2. `InteractsWithSchemas` auto-discovers it via reflection
|
||||||
|
3. Render via `{{ $this->infolist }}` (magic property) or `EmbeddedSchema::make('infolist')`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Adding `InteractsWithInfolists` trait → Rejected: deprecated shim, adds no functionality
|
||||||
|
- Extracting a static builder from `OperationRunResource` → Not needed: `infolist()` is already `static`
|
||||||
|
|
||||||
|
**Source files verified**:
|
||||||
|
- `vendor/filament/infolists/src/Concerns/InteractsWithInfolists.php` — all methods `@deprecated`
|
||||||
|
- `vendor/filament/infolists/src/Contracts/HasInfolists.php` — empty interface
|
||||||
|
- `vendor/filament/support/src/Pages/BasePage.php` — `implements HasSchemas`, uses `InteractsWithSchemas`
|
||||||
|
- `vendor/filament/support/src/Concerns/InteractsWithSchemas.php` — auto-discovers methods by name
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-002 — OperationRunResource::infolist() Compatibility
|
||||||
|
|
||||||
|
**Decision**: Call `OperationRunResource::infolist($schema)` directly from the standalone Page.
|
||||||
|
|
||||||
|
**Rationale**: The method is `public static`, uses no `$this` references, and already handles tenantless context gracefully (`Filament::getTenant()` returns null → falls back to `OperationRunLinks::tenantlessView()`).
|
||||||
|
|
||||||
|
**Requirements for the standalone Page**:
|
||||||
|
1. `public function infolist(Schema $schema): Schema` → delegates to `OperationRunResource::infolist($schema)`
|
||||||
|
2. `public function defaultInfolist(Schema $schema): Schema` → sets `->record($this->run)->columns(2)`
|
||||||
|
3. `public bool $opsUxIsTabHidden = false` property → needed for polling callback in infolist
|
||||||
|
4. Override `content()` or render via Blade `{{ $this->infolist }}`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Extracting infolist into a shared trait → Overengineered for one consumer
|
||||||
|
- Keeping hand-coded Blade → Defeats the single-source goal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-003 — Headless Resource Pattern (Empty getPages)
|
||||||
|
|
||||||
|
**Decision**: Return `[]` from `OperationRunResource::getPages()` to eliminate all auto-generated routes.
|
||||||
|
|
||||||
|
**Rationale**: `Resource::routes()` iterates `getPages()` in a `foreach` — empty array means zero route registrations. The resource class is retained as a static utility providing `::table()` and `::infolist()` schema builders.
|
||||||
|
|
||||||
|
**Impact**: The following route names will no longer exist:
|
||||||
|
- `filament.admin.resources.operations.index`
|
||||||
|
- `filament.admin.resources.operations.view`
|
||||||
|
|
||||||
|
**Files that reference these routes** (need updating):
|
||||||
|
- `tests/Feature/Verification/VerificationAuthorizationTest.php` (L38, L74) — `OperationRunResource::getUrl('view', ...)`
|
||||||
|
- `tests/Feature/OpsUx/FailureSanitizationTest.php` (L58) — `OperationRunResource::getUrl('view', ...)`
|
||||||
|
- `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` (L25) — guard test scanning for stale references (update regex)
|
||||||
|
- `tests/Feature/Verification/VerificationDbOnlyTest.php` — `ViewOperationRun` Livewire test
|
||||||
|
- `tests/Feature/OpsUx/FailureSanitizationTest.php` — `ViewOperationRun` Livewire test
|
||||||
|
- `tests/Feature/Verification/VerificationReportRenderingTest.php` — `ViewOperationRun` Livewire test
|
||||||
|
- `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` (L121) — `ListOperationRuns` Livewire test
|
||||||
|
- `tests/Feature/Monitoring/OperationsTenantScopeTest.php` (L119) — `ListOperationRuns` Livewire test
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keeping a dummy page that redirects → Adds complexity, spec says natural 404
|
||||||
|
- Excluding resource from discovery → More fragile than empty `getPages()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-004 — KPI Header Tenantless Behavior
|
||||||
|
|
||||||
|
**Decision**: Hide `OperationsKpiHeader` when no tenant context is available (Phase 1).
|
||||||
|
|
||||||
|
**Rationale**: The widget runs 6 queries all scoped by `tenant_id`. Without tenant context, all queries return 0. Showing zeros is misleading. Workspace-scoped queries are a larger refactor deferred to a separate spec.
|
||||||
|
|
||||||
|
**Implementation**: Check `Filament::getTenant()` in the widget's `getStats()` — return empty array if null. Or conditionally register the widget on the page based on tenant presence.
|
||||||
|
|
||||||
|
**Source**: `app/Filament/Widgets/Operations/OperationsKpiHeader.php` — 131 lines, 4 stat cards, `$isLazy = false`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R-005 — Legacy List Redirect (FR-078-012)
|
||||||
|
|
||||||
|
**Decision**: Add a 302 redirect from `/admin/t/{tenant}/operations` to `/admin/operations`.
|
||||||
|
|
||||||
|
**Rationale**: Unlike detail URLs which naturally 404 after page decommission, the list URL pattern may still be reached through Filament's navigation system during the transition period. A simple redirect avoids confusion.
|
||||||
|
|
||||||
|
**Note**: This is the only redirect in the spec. All detail-level legacy URLs naturally 404.
|
||||||
343
specs/078-operations-tenantless-canonical/spec.md
Normal file
343
specs/078-operations-tenantless-canonical/spec.md
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
# Feature Specification: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Feature Branch**: `078-operations-tenantless-canonical`
|
||||||
|
**Created**: 2026-02-06
|
||||||
|
**Status**: Draft
|
||||||
|
**Stack**: Filament v5 + Livewire v4 (native only)
|
||||||
|
**Input**: Eliminate dual "run detail" surfaces (tenant-scoped Filament view vs tenantless canonical viewer) and make Operations truly canonical, supportable, and secure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Executive Summary
|
||||||
|
|
||||||
|
Operations is already **canonical at the index and tenantless detail** (`/admin/operations`, `/admin/operations/{run}`), but **auto-generated tenant-scoped Filament resource routes** still exist as side-effects of panel discovery:
|
||||||
|
|
||||||
|
- Tenant-scoped detail: `/admin/t/{tenant}/operations/r/{record}`
|
||||||
|
- Tenant-scoped list: `/admin/t/{tenant}/operations`
|
||||||
|
|
||||||
|
This spec makes the tenantless detail page **the only run detail view**, removes the auto-generated tenant-scoped pages (legacy detail URLs naturally 404), and ensures **deny-as-not-found** security—without introducing non-native UI frameworks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-02-06
|
||||||
|
|
||||||
|
- Q: Should a tenant-scoped legacy URL (`/admin/t/{tenant}/operations/r/{record}`) for a run with `tenant_id = null` be allowed (redirect) or blocked (404)? → A: ~~Allow redirect~~ Superseded — FR-078-005/006 removed; legacy detail URLs naturally 404 after route decommission.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals
|
||||||
|
|
||||||
|
1. **Single canonical run detail view** — `/admin/operations/{run}`
|
||||||
|
2. **Remove auto-generated tenant-scoped pages** — decommission both `/admin/t/{tenant}/operations/r/{record}` and `/admin/t/{tenant}/operations`
|
||||||
|
3. **Clean decommission** — legacy tenant-scoped detail URLs naturally 404 after route removal (no redirect handlers needed)
|
||||||
|
4. **No duplication of UI logic** — reuse `OperationRunResource::infolist()` in the tenantless viewer
|
||||||
|
5. **Enterprise security semantics** — non-member → 404, no existence leakage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Non-Goals
|
||||||
|
|
||||||
|
- Building a new operations dashboard
|
||||||
|
- Introducing an alerts engine
|
||||||
|
- Changing operation execution semantics
|
||||||
|
- Implementing fine-grained `operations.view` capabilities (access remains workspace-membership)
|
||||||
|
- Workspace-scoped KPI header (tracked separately; header hidden in tenantless mode for Phase 1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Current State (Baseline)
|
||||||
|
|
||||||
|
**Registered routes (today):**
|
||||||
|
|
||||||
|
| Route | Pattern | Name | Handler | Scope |
|
||||||
|
|-------|---------|------|---------|-------|
|
||||||
|
| Canonical list | `GET /admin/operations` | `admin.operations.index` | `Operations.php` (custom page) | Workspace (session) |
|
||||||
|
| Canonical detail | `GET /admin/operations/{run}` | `admin.operations.view` | `TenantlessOperationRunViewer.php` | Workspace membership |
|
||||||
|
| Auto-generated list | `GET /admin/t/{tenant}/operations` | `filament.admin.resources.operations.index` | `ListOperationRuns.php` | Tenant-scoped |
|
||||||
|
| Auto-generated view | `GET /admin/t/{tenant}/operations/r/{record}` | `filament.admin.resources.operations.view` | `ViewOperationRun.php` | Tenant-scoped |
|
||||||
|
|
||||||
|
**Key constraints:**
|
||||||
|
- Tenant-scoped routes exist because `OperationRunResource` is auto-discovered in a tenant panel (`AdminPanelProvider::discoverResources`).
|
||||||
|
- All production "View run" links already point to canonical tenantless detail via `OperationRunLinks::view()` → `OperationRunLinks::tenantlessView()`.
|
||||||
|
- The resource already declares `$isScopedToTenant = false` and `$shouldRegisterNavigation = false`.
|
||||||
|
|
||||||
|
**Link helper state:**
|
||||||
|
- `OperationRunLinks::view($run, $tenant)` delegates to `tenantlessView($run)` — the `$tenant` parameter is a no-op.
|
||||||
|
- `OperationRunLinks::related($run, $tenant)` returns up to 11 contextual links (Policies, Inventory, Drift, Backup Sets, Provider Connections, etc.).
|
||||||
|
- `OperationRunUrl::view()` and `OperationRunUrl::index()` are thin wrappers around `OperationRunLinks`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Enterprise Principles (Normative)
|
||||||
|
|
||||||
|
### P-078-001 — Canonical deep links
|
||||||
|
There MUST be exactly one canonical URL to open a run: `/admin/operations/{run}`.
|
||||||
|
|
||||||
|
### P-078-002 — No tenant context dependency
|
||||||
|
Canonical run detail MUST render without tenant context and MUST NOT require `/admin/t/{tenant}`.
|
||||||
|
|
||||||
|
### P-078-003 — Deny-as-not-found
|
||||||
|
Any user not entitled to view a run MUST receive **404**, not 403. (Constitution: RBAC-UX-002)
|
||||||
|
|
||||||
|
### P-078-004 — No leakage via legacy URLs
|
||||||
|
Decommissioned tenant-scoped routes MUST NOT exist after migration. Removed routes naturally return 404 — no redirect handlers, no existence leakage.
|
||||||
|
|
||||||
|
### P-078-005 — Filament-native
|
||||||
|
Use Filament pages/resources/infolists/actions and Livewire v4 only. No custom SPA routing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Scenarios & Testing
|
||||||
|
|
||||||
|
### User Story 1 — View operation run via canonical URL (Priority: P1)
|
||||||
|
|
||||||
|
A workspace member clicks a "View run" link (from notification, widget, or operations list) and sees the full run detail at `/admin/operations/{run}` — regardless of whether the run has a tenant or not.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core feature — the single canonical detail surface that replaces the dual-surface.
|
||||||
|
|
||||||
|
**Independent Test**: Create runs with and without `tenant_id`, navigate to `/admin/operations/{run}`, assert all sections render (summary, target scope, verification report, context JSON).
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a run with `tenant_id` set and user is workspace member, **When** user visits `/admin/operations/{run}`, **Then** full detail renders including target scope, verification report (if present), summary counts, and context JSON.
|
||||||
|
2. **Given** a run with `tenant_id = null` (e.g., onboarding run), **When** user visits `/admin/operations/{run}`, **Then** detail renders without crash; target scope shows "No target scope details recorded."
|
||||||
|
3. **Given** a run with a verification report in context, **When** user visits canonical detail, **Then** verification report section renders correctly with badge rendering and acknowledgements — even though `Filament::getTenant()` returns null.
|
||||||
|
4. **Given** user is NOT a workspace member, **When** user visits `/admin/operations/{run}`, **Then** 404 (deny-as-not-found).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 — Legacy tenant-scoped detail URLs return 404 (Priority: P2)
|
||||||
|
|
||||||
|
A user has a bookmarked or old notification link pointing to `/admin/t/{tenant}/operations/r/{record}`. After route decommission, the system returns 404 — no redirect, no existence leakage.
|
||||||
|
|
||||||
|
**Why this priority**: Ensures decommissioned routes don't silently serve stale pages or leak information.
|
||||||
|
|
||||||
|
**Independent Test**: Hit legacy detail URLs; assert 404 for all users regardless of membership.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** any user (member or non-member), **When** user visits `/admin/t/{tenant}/operations/r/{record}`, **Then** 404.
|
||||||
|
2. **Given** any user, **When** user visits `/admin/operations/r/{record}`, **Then** 404 (the `/r/` slug variant also does not exist).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 — Contextual navigation from run detail (Priority: P2)
|
||||||
|
|
||||||
|
On the canonical detail page, a workspace member sees contextual "Open" actions (e.g., "Open tenant", "Policies", "Backup Set", "Provider Connection") based on the run's context — using the existing `OperationRunLinks::related()` mechanism.
|
||||||
|
|
||||||
|
**Why this priority**: Replaces the "Admin details" back-link with richer, already-implemented navigation.
|
||||||
|
|
||||||
|
**Independent Test**: Create runs of different types, verify that the related links appear in the header actions group.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a run of type `policy.sync` with `policy_id` in context, **When** viewing canonical detail, **Then** header shows "Open" group with links to Operations index, Policy, and Policies list.
|
||||||
|
2. **Given** a run with `tenant_id` and user is tenant member, **When** viewing canonical detail, **Then** related links include tenant-scoped resources (via `OperationRunLinks::related()`).
|
||||||
|
3. **Given** a run with `tenant_id = null`, **When** viewing canonical detail, **Then** no tenant-specific related links appear; only generic links (Operations index).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 — Operations list remains workspace-scoped (Priority: P3)
|
||||||
|
|
||||||
|
The `/admin/operations` page continues to show all runs for the current workspace, with an optional tenant default filter when tenant context is active.
|
||||||
|
|
||||||
|
**Why this priority**: Existing behavior; this story ensures no regression during migration.
|
||||||
|
|
||||||
|
**Independent Test**: Visit `/admin/operations` with and without tenant context; verify workspace scoping and default filter behavior.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** workspace context set but no tenant context, **When** visiting `/admin/operations`, **Then** all workspace runs shown.
|
||||||
|
2. **Given** tenant context active, **When** visiting `/admin/operations`, **Then** tenant filter defaults to active tenant but can be cleared to show all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Run with `workspace_id = 0` or null → 404 (existing behavior in `OperationRunPolicy::view`)
|
||||||
|
- Run exists but requesting user has no workspace membership → 404 (deny-as-not-found)
|
||||||
|
- Verification report section with `Filament::getTenant()` returning null → falls back to `OperationRunLinks::tenantlessView()` for previous-run URLs (already handled in infolist)
|
||||||
|
- Legacy tenant-scoped URLs (`/admin/t/{tenant}/operations/r/{record}`) → 404 (routes no longer registered after decommission)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):**
|
||||||
|
- Authorization plane: Workspace-level (not tenant-level). Operations detail requires workspace membership only.
|
||||||
|
- 404 vs 403: Non-member → 404 (RBAC-UX-002). No capability-gating for view-only operations access in this spec.
|
||||||
|
- Server-side enforcement: `OperationRunPolicy::view()` + `WorkspaceMembership` check in `TenantlessOperationRunViewer::mount()`.
|
||||||
|
- Legacy tenant-scoped routes are decommissioned (naturally 404); no redirect handlers needed (P-078-004).
|
||||||
|
- Global search: `OperationRunResource` has `$shouldRegisterNavigation = false` and no `$recordTitleAttribute` — not globally searchable.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable — no auth handshakes involved.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** No badge changes. Existing badge mappings (`OperationRunStatus`, `OperationRunOutcome`) are reused via shared `BadgeRenderer` in the reused infolist.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
#### 5.1 Canonical Run Detail — Feature-Complete Tenantless Page
|
||||||
|
|
||||||
|
- **FR-078-001**: `GET /admin/operations/{run}` MUST display: run summary (status/outcome/timestamps/initiator), target scope, verification report (DB-only), summary counts, failure summary, and context JSON (redacted).
|
||||||
|
- **FR-078-002**: Tenantless detail page MUST reuse `OperationRunResource::infolist()` via Filament v5's native schema system. `TenantlessOperationRunViewer` MUST define `public function infolist(Schema $schema): Schema` delegating to `OperationRunResource::infolist($schema)`, provide `defaultInfolist(Schema $schema)` to bind `->record($this->run)->columns(2)`, and render the schema via `{{ $this->infolist }}` (or `EmbeddedSchema::make('infolist')` in `content()`).
|
||||||
|
- **FR-078-003**: Tenantless detail MUST reuse `OperationRunLinks::related($run, $tenant)` for contextual header actions (replacing the removed "Admin details" button). If `$run->tenant` is null, only generic links (Operations index) appear.
|
||||||
|
|
||||||
|
#### 5.2 Decommission Auto-Generated Tenant-Scoped Pages
|
||||||
|
|
||||||
|
- **FR-078-004**: Remove the `'view'` page entry from `OperationRunResource::getPages()` and delete `ViewOperationRun.php`. This eliminates the auto-generated `/admin/t/{tenant}/operations/r/{record}` route.
|
||||||
|
- **FR-078-011**: Remove the `'index'` page entry from `OperationRunResource::getPages()` and delete `ListOperationRuns.php`. This eliminates the auto-generated `/admin/t/{tenant}/operations` route. The resource retains only its `table()` and `infolist()` schema builders for reuse by the custom pages.
|
||||||
|
|
||||||
|
#### 5.3 Legacy URL Handling
|
||||||
|
|
||||||
|
Legacy detail URLs (`/admin/t/{tenant}/operations/r/{record}` and `/admin/operations/r/{record}`) naturally return 404 after route decommission — no redirect handlers are needed. ~~FR-078-005 and FR-078-006 removed.~~
|
||||||
|
|
||||||
|
- **FR-078-012**: Add a convenience redirect for `GET /admin/t/{tenant}/operations`:
|
||||||
|
1. Redirect **302** to `/admin/operations`.
|
||||||
|
|
||||||
|
Note: No auth check needed since `/admin/operations` already enforces workspace membership.
|
||||||
|
|
||||||
|
#### 5.4 KPI Header Handling (Phase 1 Simplification)
|
||||||
|
|
||||||
|
- **FR-078-008**: `OperationsKpiHeader` widget MUST continue to render when tenant context is available. When no tenant context exists (tenantless pages), the KPI header SHOULD be hidden (not rendered) rather than showing zeros. Full workspace-scoped KPI support is deferred to a follow-up spec.
|
||||||
|
|
||||||
|
- **FR-078-009**: Polling/active-run queries on the canonical list page (`Operations.php`) MUST handle `tenant_id = null` runs and tenantless rendering safely. This MUST be verified by an explicit regression test covering list rendering with and without tenant context.
|
||||||
|
|
||||||
|
#### 5.5 Dead Code Cleanup
|
||||||
|
|
||||||
|
- **FR-078-010**: Delete `app/Livewire/Monitoring/OperationsDetail.php` and its Blade view `resources/views/livewire/monitoring/operations-detail.blade.php`. This component is unreferenced dead code that enforces an obsolete tenant-only abort check.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **OperationRun**: The central model. Has `workspace_id` (required for authorization), `tenant_id` (nullable — onboarding/provider-check runs may lack it), `context` (JSONB with target_scope, verification_report, etc.), `summary_counts`, `failure_summary`.
|
||||||
|
- **WorkspaceMembership**: The authorization boundary. View access requires membership in the run's workspace.
|
||||||
|
- **OperationRunLinks**: Centralized link helper. `view()` → `tenantlessView()` (already canonical). `related()` provides contextual navigation links.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Exactly one canonical run detail URL exists: `/admin/operations/{run}`. The route `filament.admin.resources.operations.view` is no longer registered.
|
||||||
|
- **SC-002**: All "View run" links across notifications, widgets, tables, and jobs resolve to `/admin/operations/{run}` (no tenant-scoped detail links remain).
|
||||||
|
- **SC-003**: Legacy tenant-scoped detail URLs return 404 after route decommission (no redirect handlers, no information leakage).
|
||||||
|
- **SC-004**: Runs with `tenant_id = null` render on canonical detail without errors.
|
||||||
|
- **SC-005**: Verification report section renders correctly on canonical detail when `Filament::getTenant()` returns null.
|
||||||
|
- **SC-006**: All existing Pest tests pass after migration (no regressions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security & RBAC
|
||||||
|
|
||||||
|
### SR-078-001 — View permissions
|
||||||
|
- Viewing operations list and run detail requires workspace membership (baseline).
|
||||||
|
- Non-member → 404 (deny-as-not-found, per RBAC-UX-002).
|
||||||
|
- No fine-grained capabilities for operations view in this spec.
|
||||||
|
|
||||||
|
### SR-078-002 — No sensitive leakage in context JSON
|
||||||
|
- Context JSON shown in UI uses allowlist-based redaction (existing behavior).
|
||||||
|
- The verification report section already sanitizes sensitive data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX Requirements
|
||||||
|
|
||||||
|
### UX-078-001 — Consistent CTA label
|
||||||
|
All run CTAs MUST use the canonical label per ux-contracts.md:
|
||||||
|
- **"View run"** (exact casing)
|
||||||
|
|
||||||
|
### UX-078-002 — Context navigation via related links
|
||||||
|
Canonical detail header MUST show an "Open" action group populated by `OperationRunLinks::related()`. This provides all relevant contextual links (Operations index, Provider Connection, Policies, Backup Sets, Restore Runs, Drift, Inventory, etc.) based on run type and context.
|
||||||
|
|
||||||
|
### UX-078-003 — Empty/missing target scope
|
||||||
|
If target scope is missing: show the existing non-blocking text "No target scope details were recorded for this run." Do not crash; degrade gracefully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests (Mandatory)
|
||||||
|
|
||||||
|
### T-078-001 — Canonical run detail renders without tenant context
|
||||||
|
- Create run with `tenant_id = null` and with `tenant_id` set
|
||||||
|
- Assert `/admin/operations/{run}` renders summary sections without crash
|
||||||
|
|
||||||
|
### T-078-002 — Legacy tenant-scoped detail URLs return 404
|
||||||
|
- Any user visiting `/admin/t/{tenant}/operations/r/{record}` → 404 (route not registered)
|
||||||
|
- Any user visiting `/admin/operations/r/{record}` → 404 (route not registered)
|
||||||
|
|
||||||
|
### T-078-004 — No auto-generated tenant-scoped routes exist
|
||||||
|
- Assert route names `filament.admin.resources.operations.view` and `filament.admin.resources.operations.index` are not registered (or return 404)
|
||||||
|
|
||||||
|
### T-078-005 — No "Admin details" link on canonical detail
|
||||||
|
- Assert canonical detail page does not render `/admin/t/.../operations/r/...` links
|
||||||
|
|
||||||
|
### T-078-006 — KPI header hidden in tenantless mode
|
||||||
|
- On `/admin/operations` without tenant context, KPI header does not render (or renders gracefully)
|
||||||
|
|
||||||
|
### T-078-007 — DB-only rendering
|
||||||
|
- Rendering canonical detail does not dispatch jobs or perform HTTP calls (existing guard tests remain valid)
|
||||||
|
|
||||||
|
### T-078-008 — Verification report renders on tenantless detail
|
||||||
|
- Create run with verification report in context and `tenant_id` set
|
||||||
|
- Visit `/admin/operations/{run}` without `Filament::getTenant()` set
|
||||||
|
- Assert verification report section renders (badge, acknowledgements, change indicator with tenantless previous-run URL)
|
||||||
|
|
||||||
|
### T-078-009 — Tenant-scoped list redirect
|
||||||
|
- `/admin/t/{tenant}/operations` redirects (302) to `/admin/operations`
|
||||||
|
|
||||||
|
### T-078-010 — Related links appear on canonical detail
|
||||||
|
- Create run of type `restore.execute` with `restore_run_id` in context
|
||||||
|
- Assert header actions include "Restore Run" link
|
||||||
|
|
||||||
|
### T-078-011 — Operations list tenantless safety
|
||||||
|
- Visit `/admin/operations` with and without tenant context
|
||||||
|
- Assert list renders successfully and includes `tenant_id = null` runs without polling/query errors
|
||||||
|
|
||||||
|
### T-078-012 — Canonical CTA label consistency
|
||||||
|
- Assert run entry points use the exact label **"View run"**
|
||||||
|
- Assert canonical detail no longer shows the legacy "Admin details" CTA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- ✅ Exactly one canonical run detail URL: `/admin/operations/{run}`
|
||||||
|
- ✅ Auto-generated tenant-scoped routes (list + view) are removed; legacy detail URLs return 404
|
||||||
|
- ✅ No "Admin details" / tenant-scoped back-links from canonical pages
|
||||||
|
- ✅ Operations list works in workspace mode; tenant context only adds default filter
|
||||||
|
- ✅ Runs with `tenant_id = null` display safely
|
||||||
|
- ✅ Verification report renders correctly without tenant context
|
||||||
|
- ✅ Related contextual links (from `OperationRunLinks::related()`) replace the removed "Admin details" button
|
||||||
|
- ✅ Dead code (`OperationsDetail.php` + Blade view) removed
|
||||||
|
- ✅ Tests cover 404 semantics, infolist rendering, and canonical link rules
|
||||||
|
- ✅ Implementation is Filament v5 + Livewire v4 native only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes (Non-Normative)
|
||||||
|
|
||||||
|
### Infolist Reuse Strategy
|
||||||
|
|
||||||
|
Use Filament v5's native schema flow on the standalone page:
|
||||||
|
- Define `infolist(Schema $schema)` and delegate to `OperationRunResource::infolist($schema)`
|
||||||
|
- Define `defaultInfolist(Schema $schema)` with `->record($this->run)->columns(2)`
|
||||||
|
- Render with `{{ $this->infolist }}` (and use `EmbeddedSchema::make('infolist')` from `content()` when needed)
|
||||||
|
|
||||||
|
### Resource After Decommission
|
||||||
|
|
||||||
|
After removing both page entries from `getPages()`, `OperationRunResource` becomes a "headless" resource — it provides `table()` and `infolist()` schema builders reused by:
|
||||||
|
- `Operations.php` (custom list page, via `OperationRunResource::table($table)`)
|
||||||
|
- `TenantlessOperationRunViewer` (canonical detail, via infolist delegation)
|
||||||
|
|
||||||
|
The resource class itself is retained; only its auto-generated routes are eliminated.
|
||||||
|
|
||||||
|
### KPI Header Deferral
|
||||||
|
|
||||||
|
`OperationsKpiHeader` currently queries 6 times by `tenant_id` and uses `ActiveRuns::existForTenant()`. Full workspace-scoping requires adding `existForWorkspace()` to `ActiveRuns` and refactoring all 6 queries. This is deferred to a separate spec to keep this migration focused. Phase 1 simply hides the KPI header when tenant context is absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Decisions
|
||||||
|
|
||||||
|
- **"Copy JSON" capability-gating**: Recommended for enterprise environments but not in scope for this spec.
|
||||||
262
specs/078-operations-tenantless-canonical/tasks.md
Normal file
262
specs/078-operations-tenantless-canonical/tasks.md
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
# Tasks: Operations Tenantless Canonical Migration
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/078-operations-tenantless-canonical/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED (Pest). This feature changes runtime routing and rendering behavior.
|
||||||
|
**Operations**: No new operations introduced. Existing `OperationRun` model is read-only in this feature.
|
||||||
|
**RBAC**: Authorization unchanged — workspace membership enforced via `OperationRunPolicy::view()`. Non-member gets 404 (deny-as-not-found). No new capabilities, no destructive actions.
|
||||||
|
**Badges**: No badge changes. Existing `BadgeRenderer` reused via shared infolist schema.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story. US1 (P1) is the MVP. US2/US3 (P2) can proceed in parallel after foundational phase. US4 (P3) is independent.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4)
|
||||||
|
- Exact file paths included in descriptions
|
||||||
|
|
||||||
|
## Path Conventions
|
||||||
|
|
||||||
|
- Standard Laravel monolith: `app/`, `resources/`, `routes/`, `tests/`, `config/`
|
||||||
|
- New spec tests: `tests/Feature/078/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Create test directory and verify branch readiness
|
||||||
|
|
||||||
|
- [X] T001 Create spec test directory `tests/Feature/078/`
|
||||||
|
- [X] T002 Verify branch is clean and on `078-operations-tenantless-canonical`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Make `OperationRunResource` headless, delete decommissioned pages, delete dead code, and fix all existing tests that reference deleted classes/routes. This phase MUST complete before any user story work.
|
||||||
|
|
||||||
|
**Why blocking**: US1 needs ViewOperationRun deleted so infolist lives on standalone page. US2 needs routes removed. US3 needs the viewer to be the sole detail surface. All existing tests must pass after this phase.
|
||||||
|
|
||||||
|
- [X] T003 Change `getPages()` to return `[]` in `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [X] T004 [P] Delete `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php`
|
||||||
|
- [X] T005 [P] Delete `app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php`
|
||||||
|
- [X] T006 [P] Delete `app/Livewire/Monitoring/OperationsDetail.php` (dead code)
|
||||||
|
- [X] T007 [P] Delete `resources/views/livewire/monitoring/operations-detail.blade.php` (dead code)
|
||||||
|
- [X] T008 Update `tests/Feature/Verification/VerificationAuthorizationTest.php` — replace `OperationRunResource::getUrl('view', ...)` with `route('admin.operations.view', ...)` and replace `ViewOperationRun` Livewire mount with `TenantlessOperationRunViewer`
|
||||||
|
- [X] T009 [P] Update `tests/Feature/OpsUx/FailureSanitizationTest.php` — replace `OperationRunResource::getUrl('view', ...)` with canonical route; replace `ViewOperationRun` mount with `TenantlessOperationRunViewer`
|
||||||
|
- [X] T010 [P] Update `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` — update guard regex to account for headless resource (no `getUrl` calls exist)
|
||||||
|
- [X] T011 [P] Update `tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php` — replace `ViewOperationRun` mount with `TenantlessOperationRunViewer`
|
||||||
|
- [X] T012 [P] Update `tests/Feature/Verification/VerificationReportRedactionTest.php` + `tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php` — replace `ViewOperationRun` mounts with `TenantlessOperationRunViewer`
|
||||||
|
- [X] T013 [P] Update `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` — remove `ListOperationRuns` test block; add route-not-registered assertion for `filament.admin.resources.operations.index`
|
||||||
|
- [X] T014 [P] Update `tests/Feature/Monitoring/OperationsTenantScopeTest.php` — remove `ListOperationRuns` reference
|
||||||
|
- [X] T015 Run `grep -r "ViewOperationRun\|ListOperationRuns" app/ tests/ resources/` to verify no stale references remain
|
||||||
|
- [X] T016 Run existing test suite for affected files: `vendor/bin/sail artisan test --compact tests/Feature/Verification/ tests/Feature/OpsUx/ tests/Feature/Monitoring/ tests/Feature/Operations/`
|
||||||
|
|
||||||
|
**Checkpoint**: All existing tests pass. Resource is headless. Dead code removed. No stale references.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — View operation run via canonical URL (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: The canonical detail page at `/admin/operations/{run}` renders with full Filament infolist (summary, target scope, verification report, counts, context JSON) — replacing the current hand-coded Blade with `OperationRunResource::infolist()` reuse via the unified schema system.
|
||||||
|
|
||||||
|
**Independent Test**: Create runs with and without `tenant_id`, visit `/admin/operations/{run}`, assert all infolist sections render.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T017 [P] [US1] Write test T-078-001 (canonical detail renders with tenant_id) in `tests/Feature/078/CanonicalDetailRenderTest.php` — create OperationRun with tenant_id, visit canonical URL as workspace member, assert 200 + infolist sections visible (status badge, outcome, timestamps, target scope, summary counts)
|
||||||
|
- [X] T018 [P] [US1] Write test T-078-001 (canonical detail renders without tenant_id) in `tests/Feature/078/CanonicalDetailRenderTest.php` — create OperationRun with tenant_id=null, visit canonical URL, assert 200 + graceful rendering ("No target scope details")
|
||||||
|
- [X] T019 [P] [US1] Write test T-078-001 (non-member gets 404) in `tests/Feature/078/CanonicalDetailRenderTest.php` — visit as non-member, assert 404
|
||||||
|
- [X] T020 [P] [US1] Write test T-078-008 (verification report renders tenantless) in `tests/Feature/078/VerificationReportTenantlessTest.php` — create run with verification_report in context, visit canonical URL, assert verification section renders with badges and acknowledgements
|
||||||
|
- [X] T021 [P] [US1] Write test T-078-007 (DB-only rendering) in `tests/Feature/078/CanonicalDetailRenderTest.php` — assert canonical detail rendering does not dispatch jobs or HTTP calls
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T022 [US1] Add `public function infolist(Schema $schema): Schema` to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` — delegates to `OperationRunResource::infolist($schema)`
|
||||||
|
- [X] T023 [US1] Add `public function defaultInfolist(Schema $schema): Schema` to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` — sets `->record($this->run)->columns(2)`
|
||||||
|
- [X] T024 [US1] Add `public bool $opsUxIsTabHidden = false` property to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` (required for polling callback in infolist)
|
||||||
|
- [X] T025 [US1] Add `public function content(Schema $schema): Schema` to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` returning `EmbeddedSchema::make('infolist')`
|
||||||
|
- [X] T026 [US1] Replace hand-coded HTML in `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` with infolist render (`{{ $this->infolist }}`)
|
||||||
|
- [X] T027 [US1] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/CanonicalDetailRenderTest.php tests/Feature/078/VerificationReportTenantlessTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Canonical detail at `/admin/operations/{run}` renders full Filament infolist. Runs with and without tenant_id render correctly. MVP is functional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Legacy tenant-scoped detail URLs return 404 (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Verify that decommissioned tenant-scoped routes naturally return 404. No redirect handlers, no existence leakage.
|
||||||
|
|
||||||
|
**Independent Test**: Hit legacy detail URLs; assert 404 for all users.
|
||||||
|
|
||||||
|
**Note**: The actual route removal was done in Phase 2 (Foundational). This phase adds the spec-required tests that validate the behavior.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T028 [P] [US2] Write test T-078-002 (legacy detail URL returns 404) in `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` — visit `/admin/t/{tenant}/operations/r/{record}` as any user, assert 404
|
||||||
|
- [X] T029 [P] [US2] Write test T-078-002 (slug variant returns 404) in `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` — visit `/admin/operations/r/{record}`, assert 404
|
||||||
|
- [X] T030 [P] [US2] Write test T-078-004 (auto-generated route names not registered) in `tests/Feature/078/LegacyRoutesReturnNotFoundTest.php` — assert `Route::has('filament.admin.resources.operations.view')` is false and `Route::has('filament.admin.resources.operations.index')` is false
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
> No additional implementation needed — routes were removed in Phase 2.
|
||||||
|
|
||||||
|
- [X] T031 [US2] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/LegacyRoutesReturnNotFoundTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: All legacy URLs return 404. Route names are not registered. No existence leakage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Contextual navigation from run detail (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Replace the "Admin details" button on canonical detail with `OperationRunLinks::related()` action group in header actions — providing richer contextual navigation (Operations index, Policy, Backup Set, Restore Run, etc.).
|
||||||
|
|
||||||
|
**Independent Test**: Create runs of different types, verify related links appear in header actions and "Admin details" link is absent.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T032 [P] [US3] Write test T-078-010 (related links appear) in `tests/Feature/078/RelatedLinksOnDetailTest.php` — create run of type `restore.execute` with `restore_run_id` in context, visit canonical detail, assert header actions include "Restore Run" link
|
||||||
|
- [X] T033 [P] [US3] Write test T-078-010 (generic links for tenantless run) in `tests/Feature/078/RelatedLinksOnDetailTest.php` — create run with tenant_id=null, assert only generic links (Operations index) appear
|
||||||
|
- [X] T034 [P] [US3] Write tests T-078-005 + T-078-012 in `tests/Feature/078/RelatedLinksOnDetailTest.php` — assert canonical detail does not render `/admin/t/.../operations/r/...` links and uses exact CTA label "View run" (legacy "Admin details" absent)
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T035 [US3] Add `getHeaderActions()` method to `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` — return actions from `OperationRunLinks::related($this->run, $this->run->tenant)`
|
||||||
|
- [X] T036 [US3] Remove "Admin details" button code (~line 61) from `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||||
|
- [X] T037 [US3] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/RelatedLinksOnDetailTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Header shows contextual "Open" action group. "Admin details" link is gone. Related links vary by run type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 — Operations list remains workspace-scoped (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Ensure no regression on `/admin/operations` list. KPI header hidden in tenantless mode. 302 redirect for decommissioned list URL.
|
||||||
|
|
||||||
|
**Independent Test**: Visit `/admin/operations` with and without tenant context; verify workspace scoping, KPI behavior, and redirect.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [X] T038 [P] [US4] Write test T-078-006 (KPI header hidden without tenant) in `tests/Feature/078/KpiHeaderTenantlessTest.php` — visit `/admin/operations` without tenant context, assert KPI stats are not rendered (empty array from `getStats()`)
|
||||||
|
- [X] T039 [P] [US4] Write test T-078-009 (tenant-scoped list redirect) in `tests/Feature/078/TenantListRedirectTest.php` — visit `/admin/t/{tenant}/operations`, assert 302 redirect to `/admin/operations`
|
||||||
|
- [X] T048 [P] [US4] Write test T-078-011 (tenantless list query safety) in `tests/Feature/078/OperationsListTenantlessSafetyTest.php` — visit `/admin/operations` with and without tenant context, assert runs including `tenant_id = null` render without errors
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [X] T040 [US4] Add tenant-null guard in `getStats()` of `app/Filament/Widgets/Operations/OperationsKpiHeader.php` — if `Filament::getTenant()` is null, return `[]`
|
||||||
|
- [X] T041 [US4] Add 302 redirect route in `routes/web.php` — `/admin/t/{tenant}/operations` redirects to `/admin/operations` (FR-078-012)
|
||||||
|
- [X] T042 [US4] Run tests: `vendor/bin/sail artisan test --compact tests/Feature/078/KpiHeaderTenantlessTest.php tests/Feature/078/TenantListRedirectTest.php tests/Feature/078/OperationsListTenantlessSafetyTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Operations list works in workspace mode. KPI hidden without tenant. List redirect works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final validation, formatting, and stale reference sweep
|
||||||
|
|
||||||
|
- [X] T043 Run `grep -r "ViewOperationRun\|ListOperationRuns\|OperationsDetail" app/ tests/ resources/` — verify zero stale references across entire codebase
|
||||||
|
- [X] T044 Run `vendor/bin/sail bin pint --dirty` — fix any formatting issues
|
||||||
|
- [X] T049 Remove obsolete temporary layout `resources/views/filament/layouts/topbar-only.blade.php`
|
||||||
|
- [X] T045 Run focused test pack: `vendor/bin/sail artisan test --compact tests/Feature/078/ tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Verification/VerificationAuthorizationTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php tests/Feature/Verification/VerificationReportRedactionTest.php tests/Feature/Verification/VerificationReportMissingOrMalformedTest.php tests/Feature/OpsUx/FailureSanitizationTest.php tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`
|
||||||
|
- [X] T046 Run quickstart.md validation: `vendor/bin/sail artisan route:list --name=filament.admin.resources.operations` — assert empty output
|
||||||
|
- [X] T047 Ask user if they want to run the full test suite: `vendor/bin/sail artisan test --compact`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories
|
||||||
|
- **US1 (Phase 3)**: Depends on Phase 2 completion (resource must be headless first)
|
||||||
|
- **US2 (Phase 4)**: Depends on Phase 2 completion (routes must be removed)
|
||||||
|
- **US3 (Phase 5)**: Depends on Phase 3 completion (viewer must have infolist before adding header actions)
|
||||||
|
- **US4 (Phase 6)**: Depends on Phase 2 completion only (independent of US1/US2/US3)
|
||||||
|
- **Polish (Phase 7)**: Depends on all desired user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: After Phase 2 — no other story dependencies. **This is the MVP.**
|
||||||
|
- **US2 (P2)**: After Phase 2 — independent of US1 (tests only, implementation in Phase 2)
|
||||||
|
- **US3 (P2)**: After US1 (Phase 3) — needs infolist on viewer before adding header actions
|
||||||
|
- **US4 (P3)**: After Phase 2 — independent of US1/US2/US3
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests written FIRST, verified to FAIL before implementation
|
||||||
|
- Implementation follows test guidance
|
||||||
|
- Story checkpoint validates independently
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
After Phase 2 completes:
|
||||||
|
- **US1 and US2 and US4** can proceed in parallel (different files, no dependencies)
|
||||||
|
- US3 must wait for US1 (same file: TenantlessOperationRunViewer.php)
|
||||||
|
|
||||||
|
Within Phase 2:
|
||||||
|
- T004, T005, T006, T007 (file deletions) can all run in parallel
|
||||||
|
- T008-T014 (test file updates) can all run in parallel
|
||||||
|
|
||||||
|
Within Phase 3 (US1):
|
||||||
|
- T017-T021 (test writing) can all run in parallel
|
||||||
|
- T022-T026 (implementation) are sequential (same file)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: After Phase 2
|
||||||
|
|
||||||
|
```
|
||||||
|
# US1 (developer/agent A):
|
||||||
|
T017-T021: Write US1 tests (parallel)
|
||||||
|
T022-T026: Implement infolist reuse (sequential, same file)
|
||||||
|
T027: Run US1 tests
|
||||||
|
|
||||||
|
# US2 (developer/agent B — can run simultaneously):
|
||||||
|
T028-T030: Write US2 tests (parallel)
|
||||||
|
T031: Run US2 tests
|
||||||
|
|
||||||
|
# US4 (developer/agent C — can run simultaneously):
|
||||||
|
T038-T039: Write US4 tests (parallel)
|
||||||
|
T040-T041: Implement KPI guard + redirect
|
||||||
|
T042: Run US4 tests
|
||||||
|
|
||||||
|
# US3 (must wait for US1):
|
||||||
|
T032-T034: Write US3 tests (parallel)
|
||||||
|
T035-T036: Implement related links
|
||||||
|
T037: Run US3 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup
|
||||||
|
2. Complete Phase 2: Foundational (headless resource + dead code + test fixes)
|
||||||
|
3. Complete Phase 3: User Story 1 (infolist reuse on canonical detail)
|
||||||
|
4. **STOP and VALIDATE**: Canonical detail renders full infolist for all run types
|
||||||
|
5. This is deployable as MVP — core value delivered
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup + Foundational -> Foundation ready (all existing tests pass)
|
||||||
|
2. Add US1 -> Canonical detail has full infolist (MVP!)
|
||||||
|
3. Add US2 -> Legacy URLs confirmed 404 (validation tests)
|
||||||
|
4. Add US3 -> Related links replace "Admin details" button
|
||||||
|
5. Add US4 -> KPI hidden + list redirect
|
||||||
|
6. Polish -> Full sweep, formatting, final validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All tests use Pest (not PHPUnit class syntax)
|
||||||
|
- Use existing `OperationRun` factory for test data setup
|
||||||
|
- `TenantlessOperationRunViewer` is the sole detail surface after migration
|
||||||
|
- `OperationRunResource` retained as headless utility (provides `::table()` and `::infolist()`)
|
||||||
|
- No new migrations, no new models, no new dependencies
|
||||||
|
- Total: 47 tasks across 7 phases
|
||||||
129
tests/Feature/078/CanonicalDetailRenderTest.php
Normal file
129
tests/Feature/078/CanonicalDetailRenderTest.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class CanonicalDetailRenderTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_renders_canonical_detail_for_a_workspace_member_when_tenant_context_exists(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
'context' => [
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_name' => 'Contoso',
|
||||||
|
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 10,
|
||||||
|
'processed' => 10,
|
||||||
|
'succeeded' => 10,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Operation run')
|
||||||
|
->assertSee('Policy sync')
|
||||||
|
->assertSee('Counts')
|
||||||
|
->assertSee('Context')
|
||||||
|
->assertSee('Contoso');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_renders_canonical_detail_gracefully_when_tenant_id_is_null(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No target scope details were recorded for this run.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_404_on_canonical_detail_for_non_members(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$otherUser] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($otherUser)
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_renders_canonical_detail_db_only_with_no_job_dispatch(): void
|
||||||
|
{
|
||||||
|
Bus::fake();
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [
|
||||||
|
'verification_report' => json_decode(
|
||||||
|
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($user, $run): void {
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Verification report');
|
||||||
|
});
|
||||||
|
|
||||||
|
Bus::assertNothingDispatched();
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/Feature/078/KpiHeaderTenantlessTest.php
Normal file
34
tests/Feature/078/KpiHeaderTenantlessTest.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class KpiHeaderTenantlessTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_hides_operations_kpi_stats_when_tenant_context_is_absent(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
OperationRun::factory()->count(3)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('Total Runs (30 days)')
|
||||||
|
->assertDontSee('Failed/Partial (7 days)')
|
||||||
|
->assertDontSee('Avg Duration (7 days)');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/Feature/078/LegacyRoutesReturnNotFoundTest.php
Normal file
42
tests/Feature/078/LegacyRoutesReturnNotFoundTest.php
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class LegacyRoutesReturnNotFoundTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_returns_404_for_legacy_tenant_scoped_operation_detail_urls(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/admin/t/'.$tenant->external_id.'/operations/r/'.$run->getKey())
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_404_for_the_admin_operations_r_record_legacy_slug_variant(): void
|
||||||
|
{
|
||||||
|
[$user] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/admin/operations/r/123')
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_does_not_register_legacy_operation_resource_route_names(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(Route::has('filament.admin.resources.operations.view'));
|
||||||
|
$this->assertFalse(Route::has('filament.admin.resources.operations.index'));
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/Feature/078/OperationsListTenantlessSafetyTest.php
Normal file
78
tests/Feature/078/OperationsListTenantlessSafetyTest.php
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class OperationsListTenantlessSafetyTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_renders_workspace_operations_list_with_tenantless_runs_when_no_tenant_context_is_set(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'initiator_name' => 'Tenantless run',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'initiator_name' => 'Tenant run',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Tenantless run')
|
||||||
|
->assertSee('Tenant run');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_renders_workspace_operations_list_safely_with_tenant_context_and_tenantless_records_present(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'initiator_name' => 'Tenantless run',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'initiator_name' => 'Tenant run',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Tenant run')
|
||||||
|
->assertDontSee('Tenantless run');
|
||||||
|
}
|
||||||
|
}
|
||||||
94
tests/Feature/078/RelatedLinksOnDetailTest.php
Normal file
94
tests/Feature/078/RelatedLinksOnDetailTest.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class RelatedLinksOnDetailTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_shows_restore_related_links_on_canonical_detail_for_restore_execute_runs(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$restoreRun = RestoreRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'restore.execute',
|
||||||
|
'context' => [
|
||||||
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expectedUrl = OperationRunLinks::related($run->loadMissing('tenant'), $tenant)['Restore Run'] ?? null;
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Open')
|
||||||
|
->assertSee('Restore Run');
|
||||||
|
|
||||||
|
$this->assertIsString($expectedUrl);
|
||||||
|
$response->assertSee((string) $expectedUrl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shows_only_generic_links_for_tenantless_runs_on_canonical_detail(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'type' => 'restore.execute',
|
||||||
|
'context' => [
|
||||||
|
'restore_run_id' => 999,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Operations')
|
||||||
|
->assertSee(route('admin.operations.index'), false)
|
||||||
|
->assertDontSee('Restore Run');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_does_not_show_legacy_admin_details_cta_and_keeps_canonical_view_run_label(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('Admin details')
|
||||||
|
->assertDontSee('/admin/t/'.$tenant->external_id.'/operations/r/'.$run->getKey(), false);
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('View run');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tests/Feature/078/TenantListRedirectTest.php
Normal file
26
tests/Feature/078/TenantListRedirectTest.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class TenantListRedirectTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_redirects_legacy_tenant_scoped_operations_list_url_to_canonical_operations_index(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin/t/'.$tenant->external_id.'/operations')
|
||||||
|
->assertStatus(302)
|
||||||
|
->assertRedirect(route('admin.operations.index'));
|
||||||
|
}
|
||||||
|
}
|
||||||
63
tests/Feature/078/VerificationReportTenantlessTest.php
Normal file
63
tests/Feature/078/VerificationReportTenantlessTest.php
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class VerificationReportTenantlessTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_renders_verification_report_on_canonical_detail_without_filament_tenant_context(): void
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
|
$report = json_decode(
|
||||||
|
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR,
|
||||||
|
);
|
||||||
|
|
||||||
|
$previousRun = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [
|
||||||
|
'verification_report' => $report,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report['previous_report_id'] = (int) $previousRun->getKey();
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [
|
||||||
|
'verification_report' => $report,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Verification report')
|
||||||
|
->assertSee('Open previous verification')
|
||||||
|
->assertSee('/admin/operations/'.((int) $previousRun->getKey()), false)
|
||||||
|
->assertSee('Token acquisition works');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
|
use App\Filament\Pages\Monitoring\Operations;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -68,7 +69,7 @@
|
|||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Operation run')
|
->assertSee('Operation run')
|
||||||
->assertSee('/admin/t/'.((int) $tenant->getKey()).'/operations/r/'.((int) $run->getKey()));
|
->assertDontSee('/admin/t/'.((int) $tenant->getKey()).'/operations/r/'.((int) $run->getKey()));
|
||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
@ -116,7 +117,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ListOperationRuns::class)
|
->test(Operations::class)
|
||||||
->assertCanSeeTableRecords([$runA])
|
->assertCanSeeTableRecords([$runA])
|
||||||
->assertCanNotSeeTableRecords([$runB]);
|
->assertCanNotSeeTableRecords([$runB]);
|
||||||
|
|
||||||
@ -125,6 +126,11 @@
|
|||||||
->assertCanSeeTableRecords([$runA, $runB]);
|
->assertCanSeeTableRecords([$runA, $runB]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not register legacy operation resource routes', function (): void {
|
||||||
|
expect(Route::has('filament.admin.resources.operations.index'))->toBeFalse();
|
||||||
|
expect(Route::has('filament.admin.resources.operations.view'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
it('has reserved Monitoring placeholder pages for Alerts and Audit Log', function (): void {
|
it('has reserved Monitoring placeholder pages for Alerts and Audit Log', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||||
|
|||||||
@ -26,10 +26,10 @@
|
|||||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get('/admin/operations')
|
->get('/admin/operations')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Total Runs (30 days)')
|
->assertDontSee('Total Runs (30 days)')
|
||||||
->assertSee('Active Runs')
|
->assertDontSee('Active Runs')
|
||||||
->assertSee('Failed/Partial (7 days)')
|
->assertDontSee('Failed/Partial (7 days)')
|
||||||
->assertSee('Avg Duration (7 days)')
|
->assertDontSee('Avg Duration (7 days)')
|
||||||
->assertSee('All')
|
->assertSee('All')
|
||||||
->assertSee('Active')
|
->assertSee('Active')
|
||||||
->assertSee('Succeeded')
|
->assertSee('Succeeded')
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages\ListOperationRuns;
|
use App\Filament\Pages\Monitoring\Operations;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
@ -118,7 +118,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListOperationRuns::class)
|
->test(Operations::class)
|
||||||
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA])
|
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA])
|
||||||
->assertCanNotSeeTableRecords([$runActiveB, $runFailedB])
|
->assertCanNotSeeTableRecords([$runActiveB, $runFailedB])
|
||||||
->set('activeTab', 'active')
|
->set('activeTab', 'active')
|
||||||
|
|||||||
@ -22,7 +22,8 @@
|
|||||||
|
|
||||||
$contents = File::get($path);
|
$contents = File::get($path);
|
||||||
|
|
||||||
if (preg_match("/\\bOperationRunResource::getUrl\(\\s*'view'/", $contents) === 1) {
|
if (preg_match("/\\bOperationRunResource::getUrl\(\\s*'view'/", $contents) === 1
|
||||||
|
|| preg_match("/route\(\s*'filament\.admin\.resources\.operations\.view'/", $contents) === 1) {
|
||||||
$violations[] = $path;
|
$violations[] = $path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Notifications\DatabaseNotification;
|
use Illuminate\Notifications\DatabaseNotification;
|
||||||
@ -55,6 +54,6 @@
|
|||||||
expect($notificationJson)->not->toContain('test.user@example.com');
|
expect($notificationJson)->not->toContain('test.user@example.com');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -35,7 +34,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertStatus(404);
|
->assertStatus(404);
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->create([
|
$connection = ProviderConnection::factory()->create([
|
||||||
@ -71,7 +70,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Verification report');
|
->assertSee('Verification report');
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -24,7 +24,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($run): void {
|
assertNoOutboundHttp(function () use ($run): void {
|
||||||
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
|
Livewire::test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
->assertSee('Verification report')
|
->assertSee('Verification report')
|
||||||
->assertSee('Verification report unavailable');
|
->assertSee('Verification report unavailable');
|
||||||
});
|
});
|
||||||
@ -52,7 +52,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($run): void {
|
assertNoOutboundHttp(function () use ($run): void {
|
||||||
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
|
Livewire::test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
->assertSee('Verification report')
|
->assertSee('Verification report')
|
||||||
->assertSee('Verification report unavailable');
|
->assertSee('Verification report unavailable');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Verification\VerificationReportFingerprint;
|
use App\Support\Verification\VerificationReportFingerprint;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -54,7 +54,7 @@
|
|||||||
$fingerprint = VerificationReportFingerprint::forReport($report);
|
$fingerprint = VerificationReportFingerprint::forReport($report);
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($run, $fingerprint): void {
|
assertNoOutboundHttp(function () use ($run, $fingerprint): void {
|
||||||
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
|
Livewire::test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
->assertSee('Verification report')
|
->assertSee('Verification report')
|
||||||
->assertSee('Open previous verification')
|
->assertSee('Open previous verification')
|
||||||
->assertSee($fingerprint)
|
->assertSee($fingerprint)
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -45,7 +45,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
assertNoOutboundHttp(function () use ($run): void {
|
assertNoOutboundHttp(function () use ($run): void {
|
||||||
$component = Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
|
$component = Livewire::test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
->assertSee('Verification report')
|
->assertSee('Verification report')
|
||||||
->assertSee('Blocked')
|
->assertSee('Blocked')
|
||||||
->assertSee('Token acquisition works');
|
->assertSee('Token acquisition works');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user