## Summary - add explicit BaselineSnapshot lifecycle truth with conservative backfill and a shared truth resolver - block baseline compare from building, incomplete, or superseded snapshots and align workspace/tenant UI truth surfaces with effective snapshot state - surface artifact truth separately from operation outcome across baseline profile, snapshot, compare, and operation run pages ## Testing - integrated browser smoke test on the active feature surfaces - `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotTruthSurfaceTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` - targeted baseline lifecycle and compare guard coverage added in Pest - `vendor/bin/sail bin pint --dirty --format agent` ## Notes - Livewire v4 compliance preserved - no panel provider registration changes were needed; Laravel 12 providers remain in `bootstrap/providers.php` - global search remains disabled for the affected baseline resources by design - destructive actions remain confirmation-gated; capture and compare actions keep their existing authorization and confirmation behavior - no new panel assets were added; existing deploy flow for `filament:assets` is unchanged Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #189
755 lines
31 KiB
PHP
755 lines
31 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Resources\BaselineProfileResource\Pages;
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineSnapshot;
|
|
use App\Models\BaselineTenantAssignment;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeCatalog;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Baselines\BaselineCaptureMode;
|
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
|
use App\Support\Baselines\BaselineProfileStatus;
|
|
use App\Support\Baselines\BaselineReasonCodes;
|
|
use App\Support\Filament\FilterOptionCatalog;
|
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\ActionGroup;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Forms\Components\Placeholder;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Infolists\Components\TextEntry;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\Rules\Unique;
|
|
use UnitEnum;
|
|
|
|
class BaselineProfileResource extends Resource
|
|
{
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static bool $isScopedToTenant = false;
|
|
|
|
protected static ?string $model = BaselineProfile::class;
|
|
|
|
protected static ?string $slug = 'baseline-profiles';
|
|
|
|
protected static bool $isGloballySearchable = false;
|
|
|
|
protected static ?string $recordTitleAttribute = 'name';
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
|
|
protected static ?string $navigationLabel = 'Baselines';
|
|
|
|
protected static ?int $navigationSort = 1;
|
|
|
|
public static function shouldRegisterNavigation(): bool
|
|
{
|
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
|
return false;
|
|
}
|
|
|
|
return parent::shouldRegisterNavigation();
|
|
}
|
|
|
|
public static function canViewAny(): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$workspace = self::resolveWorkspace();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $workspace)
|
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
|
|
}
|
|
|
|
public static function canCreate(): bool
|
|
{
|
|
return self::hasManageCapability();
|
|
}
|
|
|
|
public static function canEdit(Model $record): bool
|
|
{
|
|
return self::hasManageCapability();
|
|
}
|
|
|
|
public static function canDelete(Model $record): bool
|
|
{
|
|
return self::hasManageCapability();
|
|
}
|
|
|
|
public static function canView(Model $record): bool
|
|
{
|
|
return self::canViewAny();
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
return parent::getEloquentQuery()
|
|
->with(['activeSnapshot', 'createdByUser'])
|
|
->when(
|
|
$workspaceId !== null,
|
|
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
|
)
|
|
->when(
|
|
$workspaceId === null,
|
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
|
);
|
|
}
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make('Profile')
|
|
->schema([
|
|
TextInput::make('name')
|
|
->required()
|
|
->maxLength(255)
|
|
->rule(fn (?BaselineProfile $record): Unique => Rule::unique('baseline_profiles', 'name')
|
|
->where('workspace_id', $record?->workspace_id ?? app(WorkspaceContext::class)->currentWorkspaceId(request()))
|
|
->ignore($record))
|
|
->helperText('A descriptive name for this baseline profile.'),
|
|
Textarea::make('description')
|
|
->rows(3)
|
|
->maxLength(1000)
|
|
->helperText('Explain the purpose and scope of this baseline.'),
|
|
]),
|
|
Section::make('Controls')
|
|
->schema([
|
|
Select::make('status')
|
|
->required()
|
|
->options(fn (?BaselineProfile $record): array => self::statusOptionsForRecord($record))
|
|
->default(BaselineProfileStatus::Draft->value)
|
|
->native(false)
|
|
->disabled(fn (?BaselineProfile $record): bool => $record?->status === BaselineProfileStatus::Archived)
|
|
->helperText(fn (?BaselineProfile $record): string => match ($record?->status) {
|
|
BaselineProfileStatus::Archived => 'Archived baselines cannot be reactivated.',
|
|
BaselineProfileStatus::Active => 'Changing status to Archived is permanent.',
|
|
default => 'Only active baselines are enforced during compliance checks.',
|
|
}),
|
|
Select::make('capture_mode')
|
|
->label('Capture mode')
|
|
->required()
|
|
->options(BaselineCaptureMode::selectOptions())
|
|
->default(BaselineCaptureMode::Opportunistic->value)
|
|
->native(false)
|
|
->disabled(fn (?BaselineProfile $record): bool => $record?->status === BaselineProfileStatus::Archived)
|
|
->disableOptionWhen(function (string $value): bool {
|
|
if ($value !== BaselineCaptureMode::FullContent->value) {
|
|
return false;
|
|
}
|
|
|
|
return ! app(BaselineFullContentRolloutGate::class)->enabled();
|
|
})
|
|
->helperText(fn (): string => app(BaselineFullContentRolloutGate::class)->enabled()
|
|
? 'Full content capture enables deep drift detection by capturing policy evidence on demand.'
|
|
: 'Full content capture is currently disabled by rollout configuration.'),
|
|
TextInput::make('version_label')
|
|
->label('Version label')
|
|
->maxLength(50)
|
|
->placeholder('e.g. v2.1 — February rollout')
|
|
->helperText('Optional label to identify this version.'),
|
|
Select::make('scope_jsonb.policy_types')
|
|
->label('Policy types')
|
|
->multiple()
|
|
->options(self::policyTypeOptions())
|
|
->helperText('Leave empty to include all supported policy types (excluding foundations).')
|
|
->native(false),
|
|
Select::make('scope_jsonb.foundation_types')
|
|
->label('Foundations')
|
|
->multiple()
|
|
->options(self::foundationTypeOptions())
|
|
->helperText('Leave empty to exclude foundations. Select foundations to include them.')
|
|
->native(false),
|
|
Placeholder::make('metadata')
|
|
->label('Last modified')
|
|
->content(fn (?BaselineProfile $record): string => $record?->updated_at
|
|
? $record->updated_at->diffForHumans()
|
|
: '—')
|
|
->visible(fn (?BaselineProfile $record): bool => $record !== null),
|
|
])
|
|
->columns(2),
|
|
]);
|
|
}
|
|
|
|
public static function infolist(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make('Profile')
|
|
->schema([
|
|
TextEntry::make('name'),
|
|
TextEntry::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)),
|
|
TextEntry::make('capture_mode')
|
|
->label('Capture mode')
|
|
->badge()
|
|
->formatStateUsing(function (mixed $state): string {
|
|
if ($state instanceof BaselineCaptureMode) {
|
|
return $state->label();
|
|
}
|
|
|
|
$parsed = is_string($state) ? BaselineCaptureMode::tryFrom($state) : null;
|
|
|
|
return $parsed?->label() ?? (is_string($state) ? $state : '—');
|
|
})
|
|
->color(function (mixed $state): string {
|
|
$mode = $state instanceof BaselineCaptureMode
|
|
? $state
|
|
: (is_string($state) ? BaselineCaptureMode::tryFrom($state) : null);
|
|
|
|
return match ($mode) {
|
|
BaselineCaptureMode::FullContent => 'success',
|
|
BaselineCaptureMode::Opportunistic => 'warning',
|
|
BaselineCaptureMode::MetaOnly => 'gray',
|
|
default => 'gray',
|
|
};
|
|
}),
|
|
TextEntry::make('version_label')
|
|
->label('Version')
|
|
->placeholder('—'),
|
|
TextEntry::make('description')
|
|
->placeholder('No description')
|
|
->columnSpanFull(),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
Section::make('Scope')
|
|
->schema([
|
|
TextEntry::make('scope_jsonb.policy_types')
|
|
->label('Policy types')
|
|
->badge()
|
|
->formatStateUsing(function (string $state): string {
|
|
$options = self::policyTypeOptions();
|
|
|
|
return $options[$state] ?? $state;
|
|
})
|
|
->placeholder('All supported policy types (excluding foundations)'),
|
|
TextEntry::make('scope_jsonb.foundation_types')
|
|
->label('Foundations')
|
|
->badge()
|
|
->formatStateUsing(function (string $state): string {
|
|
$options = self::foundationTypeOptions();
|
|
|
|
return $options[$state] ?? $state;
|
|
})
|
|
->placeholder('None'),
|
|
])
|
|
->columnSpanFull(),
|
|
Section::make('Baseline truth')
|
|
->schema([
|
|
TextEntry::make('current_snapshot_truth')
|
|
->label('Current snapshot')
|
|
->state(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record)),
|
|
TextEntry::make('latest_attempted_snapshot_truth')
|
|
->label('Latest attempt')
|
|
->state(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record)),
|
|
TextEntry::make('compare_readiness')
|
|
->label('Compare readiness')
|
|
->badge()
|
|
->state(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
|
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
|
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record)),
|
|
TextEntry::make('baseline_next_step')
|
|
->label('Next step')
|
|
->state(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
|
->columnSpanFull(),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
Section::make('Metadata')
|
|
->schema([
|
|
TextEntry::make('createdByUser.name')
|
|
->label('Created by')
|
|
->placeholder('—'),
|
|
TextEntry::make('created_at')
|
|
->dateTime(),
|
|
TextEntry::make('updated_at')
|
|
->dateTime(),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
$workspace = self::resolveWorkspace();
|
|
|
|
return $table
|
|
->defaultSort('name')
|
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->searchable()
|
|
->sortable(),
|
|
TextColumn::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus))
|
|
->sortable(),
|
|
TextColumn::make('capture_mode')
|
|
->label('Capture mode')
|
|
->badge()
|
|
->formatStateUsing(function (mixed $state): string {
|
|
if ($state instanceof BaselineCaptureMode) {
|
|
return $state->label();
|
|
}
|
|
|
|
$parsed = is_string($state) ? BaselineCaptureMode::tryFrom($state) : null;
|
|
|
|
return $parsed?->label() ?? (is_string($state) ? $state : '—');
|
|
})
|
|
->color(function (mixed $state): string {
|
|
$mode = $state instanceof BaselineCaptureMode
|
|
? $state
|
|
: (is_string($state) ? BaselineCaptureMode::tryFrom($state) : null);
|
|
|
|
return match ($mode) {
|
|
BaselineCaptureMode::FullContent => 'success',
|
|
BaselineCaptureMode::Opportunistic => 'warning',
|
|
BaselineCaptureMode::MetaOnly => 'gray',
|
|
default => 'gray',
|
|
};
|
|
})
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
TextColumn::make('version_label')
|
|
->label('Version')
|
|
->placeholder('—'),
|
|
TextColumn::make('tenant_assignments_count')
|
|
->label('Assigned tenants')
|
|
->counts('tenantAssignments'),
|
|
TextColumn::make('current_snapshot_truth')
|
|
->label('Current snapshot')
|
|
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
|
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
|
->wrap(),
|
|
TextColumn::make('latest_attempted_snapshot_truth')
|
|
->label('Latest attempt')
|
|
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
|
|
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
|
|
->wrap(),
|
|
TextColumn::make('compare_readiness')
|
|
->label('Compare readiness')
|
|
->badge()
|
|
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
|
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
|
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
|
|
->wrap(),
|
|
TextColumn::make('baseline_next_step')
|
|
->label('Next step')
|
|
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
|
->wrap(),
|
|
TextColumn::make('created_at')
|
|
->dateTime()
|
|
->sortable()
|
|
->toggleable(isToggledHiddenByDefault: true),
|
|
])
|
|
->filters([
|
|
\Filament\Tables\Filters\SelectFilter::make('status')
|
|
->options(FilterOptionCatalog::baselineProfileStatuses()),
|
|
])
|
|
->actions([
|
|
Action::make('view')
|
|
->label('View')
|
|
->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
|
|
->icon('heroicon-o-eye'),
|
|
ActionGroup::make([
|
|
Action::make('edit')
|
|
->label('Edit')
|
|
->url(fn (BaselineProfile $record): string => static::getUrl('edit', ['record' => $record]))
|
|
->icon('heroicon-o-pencil-square')
|
|
->visible(fn (): bool => self::hasManageCapability()),
|
|
self::archiveTableAction($workspace),
|
|
])->label('More'),
|
|
])
|
|
->bulkActions([
|
|
BulkActionGroup::make([])->label('More'),
|
|
])
|
|
->emptyStateHeading('No baseline profiles')
|
|
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
|
|
->emptyStateActions([
|
|
Action::make('create')
|
|
->label('Create baseline profile')
|
|
->url(fn (): string => static::getUrl('create'))
|
|
->icon('heroicon-o-plus')
|
|
->visible(fn (): bool => self::hasManageCapability()),
|
|
]);
|
|
}
|
|
|
|
public static function getRelations(): array
|
|
{
|
|
return [
|
|
BaselineProfileResource\RelationManagers\BaselineTenantAssignmentsRelationManager::class,
|
|
];
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListBaselineProfiles::route('/'),
|
|
'create' => Pages\CreateBaselineProfile::route('/create'),
|
|
'view' => Pages\ViewBaselineProfile::route('/{record}'),
|
|
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function policyTypeOptions(): array
|
|
{
|
|
return collect(InventoryPolicyTypeMeta::supported())
|
|
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
|
->mapWithKeys(fn (array $row): array => [
|
|
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
|
|
])
|
|
->sort()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function foundationTypeOptions(): array
|
|
{
|
|
return collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
|
|
->filter(fn (array $row): bool => filled($row['type'] ?? null))
|
|
->mapWithKeys(fn (array $row): array => [
|
|
(string) $row['type'] => InventoryPolicyTypeMeta::baselineCompareLabel((string) $row['type']) ?? (string) ($row['label'] ?? $row['type']),
|
|
])
|
|
->sort()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $metadata
|
|
*/
|
|
public static function audit(BaselineProfile $record, AuditActionId $actionId, array $metadata): void
|
|
{
|
|
$workspace = $record->workspace;
|
|
|
|
if ($workspace === null) {
|
|
return;
|
|
}
|
|
|
|
$actor = auth()->user();
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $workspace,
|
|
action: $actionId->value,
|
|
context: ['metadata' => $metadata],
|
|
actor: $actor instanceof User ? $actor : null,
|
|
resourceType: 'baseline_profile',
|
|
resourceId: (string) $record->getKey(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Status options scoped to valid transitions from the current record state.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private static function statusOptionsForRecord(?BaselineProfile $record): array
|
|
{
|
|
if ($record === null) {
|
|
return [BaselineProfileStatus::Draft->value => BaselineProfileStatus::Draft->label()];
|
|
}
|
|
|
|
$currentStatus = $record->status instanceof BaselineProfileStatus
|
|
? $record->status
|
|
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
|
|
|
|
return $currentStatus->selectOptions();
|
|
}
|
|
|
|
private static function resolveWorkspace(): ?Workspace
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId === null) {
|
|
return null;
|
|
}
|
|
|
|
return Workspace::query()->whereKey($workspaceId)->first();
|
|
}
|
|
|
|
private static function hasManageCapability(): bool
|
|
{
|
|
$user = auth()->user();
|
|
$workspace = self::resolveWorkspace();
|
|
|
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
|
|
|
|
return $resolver->isMember($user, $workspace)
|
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
|
}
|
|
|
|
private static function archiveTableAction(?Workspace $workspace): Action
|
|
{
|
|
$action = Action::make('archive')
|
|
->label('Archive')
|
|
->icon('heroicon-o-archive-box')
|
|
->color('warning')
|
|
->requiresConfirmation()
|
|
->modalHeading('Archive baseline profile')
|
|
->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.')
|
|
->hidden(fn (BaselineProfile $record): bool => $record->status === BaselineProfileStatus::Archived)
|
|
->action(function (BaselineProfile $record): void {
|
|
if (! self::hasManageCapability()) {
|
|
throw new AuthorizationException;
|
|
}
|
|
|
|
$record->forceFill(['status' => BaselineProfileStatus::Archived->value])->save();
|
|
|
|
self::audit($record, AuditActionId::BaselineProfileArchived, [
|
|
'baseline_profile_id' => (int) $record->getKey(),
|
|
'name' => (string) $record->name,
|
|
]);
|
|
|
|
Notification::make()
|
|
->title('Baseline profile archived')
|
|
->success()
|
|
->send();
|
|
});
|
|
|
|
if ($workspace instanceof Workspace) {
|
|
$action = WorkspaceUiEnforcement::forTableAction($action, $workspace)
|
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
|
->destructive()
|
|
->apply();
|
|
}
|
|
|
|
return $action;
|
|
}
|
|
|
|
private static function currentSnapshotLabel(BaselineProfile $profile): string
|
|
{
|
|
$snapshot = self::effectiveSnapshot($profile);
|
|
|
|
if (! $snapshot instanceof BaselineSnapshot) {
|
|
return 'No complete snapshot';
|
|
}
|
|
|
|
return self::snapshotReference($snapshot);
|
|
}
|
|
|
|
private static function currentSnapshotDescription(BaselineProfile $profile): ?string
|
|
{
|
|
$snapshot = self::effectiveSnapshot($profile);
|
|
|
|
if (! $snapshot instanceof BaselineSnapshot) {
|
|
return self::compareAvailabilityEnvelope($profile)?->shortExplanation;
|
|
}
|
|
|
|
return $snapshot->captured_at?->toDayDateTimeString();
|
|
}
|
|
|
|
private static function latestAttemptedSnapshotLabel(BaselineProfile $profile): string
|
|
{
|
|
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
|
|
|
if (! $latestAttempt instanceof BaselineSnapshot) {
|
|
return 'No capture attempts yet';
|
|
}
|
|
|
|
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
|
|
|
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
|
return 'Matches current snapshot';
|
|
}
|
|
|
|
return self::snapshotReference($latestAttempt);
|
|
}
|
|
|
|
private static function latestAttemptedSnapshotDescription(BaselineProfile $profile): ?string
|
|
{
|
|
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
|
|
|
if (! $latestAttempt instanceof BaselineSnapshot) {
|
|
return null;
|
|
}
|
|
|
|
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
|
|
|
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
|
return 'No newer attempt is pending.';
|
|
}
|
|
|
|
return $latestAttempt->captured_at?->toDayDateTimeString();
|
|
}
|
|
|
|
private static function compareReadinessLabel(BaselineProfile $profile): string
|
|
{
|
|
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
|
|
}
|
|
|
|
private static function compareReadinessColor(BaselineProfile $profile): string
|
|
{
|
|
return match (self::compareAvailabilityReason($profile)) {
|
|
null => 'success',
|
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
|
default => 'warning',
|
|
};
|
|
}
|
|
|
|
private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
|
{
|
|
return match (self::compareAvailabilityReason($profile)) {
|
|
null => 'heroicon-m-check-badge',
|
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
|
default => 'heroicon-m-exclamation-triangle',
|
|
};
|
|
}
|
|
|
|
private static function profileNextStep(BaselineProfile $profile): string
|
|
{
|
|
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
|
|
}
|
|
|
|
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
{
|
|
return app(BaselineSnapshotTruthResolver::class)->resolveEffectiveSnapshot($profile);
|
|
}
|
|
|
|
private static function latestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
{
|
|
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
|
}
|
|
|
|
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
|
{
|
|
$status = $profile->status instanceof BaselineProfileStatus
|
|
? $profile->status
|
|
: BaselineProfileStatus::tryFrom((string) $profile->status);
|
|
|
|
if ($status !== BaselineProfileStatus::Active) {
|
|
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
|
}
|
|
|
|
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
|
$reasonCode = $resolution['reason_code'] ?? null;
|
|
|
|
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
|
return trim($reasonCode);
|
|
}
|
|
|
|
if (! self::hasEligibleCompareTarget($profile)) {
|
|
return BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static function compareAvailabilityEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
|
{
|
|
$reasonCode = self::compareAvailabilityReason($profile);
|
|
|
|
if (! is_string($reasonCode)) {
|
|
return null;
|
|
}
|
|
|
|
return app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
|
}
|
|
|
|
private static function snapshotReference(BaselineSnapshot $snapshot): string
|
|
{
|
|
$lifecycleLabel = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label;
|
|
|
|
return sprintf('Snapshot #%d (%s)', (int) $snapshot->getKey(), $lifecycleLabel);
|
|
}
|
|
|
|
private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$tenantIds = BaselineTenantAssignment::query()
|
|
->where('workspace_id', (int) $profile->workspace_id)
|
|
->where('baseline_profile_id', (int) $profile->getKey())
|
|
->pluck('tenant_id')
|
|
->all();
|
|
|
|
if ($tenantIds === []) {
|
|
return false;
|
|
}
|
|
|
|
$resolver = app(CapabilityResolver::class);
|
|
|
|
return Tenant::query()
|
|
->where('workspace_id', (int) $profile->workspace_id)
|
|
->whereIn('id', $tenantIds)
|
|
->get(['id'])
|
|
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
|
}
|
|
}
|