## Summary - introduce a shared operator outcome taxonomy with semantic axes, severity bands, and next-action policy - apply the taxonomy to operations, evidence/review completeness, baseline semantics, and restore semantics - harden badge rendering, tenant-safe filtering/search behavior, and operator-facing summary/notification wording - add the spec kit artifacts, reference documentation, and regression coverage for diagnostic-vs-primary state handling ## Testing - focused Pest coverage for taxonomy registry and badge guardrails - operations presentation and notification tests - evidence, baseline, restore, and tenant-scope regression tests ## Notes - Livewire v4.0+ compliance is preserved in the existing Filament v5 stack - panel provider registration remains unchanged in bootstrap/providers.php - no new globally searchable resource was added; adopted resources remain tenant-safe and out of global search where required - no new destructive action family was introduced; existing actions keep their current authorization and confirmation behavior - no new frontend asset strategy was introduced; existing deploy flow with filament:assets remains unchanged Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #186
648 lines
28 KiB
PHP
648 lines
28 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\EvidenceSnapshotItem;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Evidence\EvidenceSnapshotService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeCatalog;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\Rbac\UiTooltips;
|
|
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 BackedEnum;
|
|
use Filament\Actions;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Infolists\Components\RepeatableEntry;
|
|
use Filament\Infolists\Components\TextEntry;
|
|
use Filament\Infolists\Components\ViewEntry;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Panel;
|
|
use Filament\Resources\Pages\PageRegistration;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Routing\Route;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Route as RouteFacade;
|
|
use Illuminate\Support\Str;
|
|
use UnitEnum;
|
|
|
|
class EvidenceSnapshotResource extends Resource
|
|
{
|
|
use InteractsWithTenantOwnedRecords;
|
|
use ResolvesPanelTenantContext;
|
|
|
|
protected static ?string $model = EvidenceSnapshot::class;
|
|
|
|
protected static ?string $slug = 'evidence';
|
|
|
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
|
|
protected static bool $isGloballySearchable = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
|
|
protected static ?string $navigationLabel = 'Evidence';
|
|
|
|
protected static ?int $navigationSort = 55;
|
|
|
|
public static function canViewAny(): bool
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can(Capabilities::EVIDENCE_VIEW, $tenant);
|
|
}
|
|
|
|
public static function canView(Model $record): bool
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
|
return false;
|
|
}
|
|
|
|
return ! $record instanceof EvidenceSnapshot
|
|
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
return static::getTenantOwnedEloquentQuery()->with(['tenant', 'initiator', 'operationRun', 'items']);
|
|
}
|
|
|
|
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
|
{
|
|
return static::resolveTenantOwnedRecordOrFail($record);
|
|
}
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema;
|
|
}
|
|
|
|
public static function infolist(Schema $schema): Schema
|
|
{
|
|
return $schema->schema([
|
|
Section::make('Snapshot')
|
|
->schema([
|
|
TextEntry::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)),
|
|
TextEntry::make('completeness_state')
|
|
->label('Completeness')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
|
TextEntry::make('tenant.name')->label('Tenant'),
|
|
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
|
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
|
TextEntry::make('operationRun.id')
|
|
->label('Operation run')
|
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
|
->openUrlInNewTab(),
|
|
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
|
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
|
])
|
|
->columns(2),
|
|
Section::make('Summary')
|
|
->schema([
|
|
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
|
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
|
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
|
TextEntry::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value))->placeholder('—'),
|
|
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
|
])
|
|
->columns(2),
|
|
Section::make('Evidence dimensions')
|
|
->schema([
|
|
RepeatableEntry::make('items')
|
|
->hiddenLabel()
|
|
->schema([
|
|
TextEntry::make('dimension_key')->label('Dimension')
|
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
TextEntry::make('state')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
|
TextEntry::make('source_kind')->label('Source')
|
|
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
|
|
ViewEntry::make('summary_payload_highlights')
|
|
->label('Summary')
|
|
->view('filament.infolists.entries.evidence-dimension-summary')
|
|
->state(fn (EvidenceSnapshotItem $record): array => static::dimensionSummaryPresentation($record))
|
|
->columnSpanFull(),
|
|
ViewEntry::make('summary_payload_raw')
|
|
->label('Raw summary JSON')
|
|
->view('filament.infolists.entries.snapshot-json')
|
|
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
|
->columnSpanFull(),
|
|
])
|
|
->columns(4),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->defaultSort('created_at', 'desc')
|
|
->recordUrl(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record]))
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
|
->sortable(),
|
|
Tables\Columns\TextColumn::make('completeness_state')
|
|
->label('Completeness')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
|
->sortable(),
|
|
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
|
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
|
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
|
])
|
|
->filters([
|
|
Tables\Filters\SelectFilter::make('status')
|
|
->options(BadgeCatalog::options(BadgeDomain::EvidenceSnapshotStatus, EvidenceSnapshotStatus::values())),
|
|
Tables\Filters\SelectFilter::make('completeness_state')
|
|
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
|
])
|
|
->actions([
|
|
Actions\Action::make('view_snapshot')
|
|
->label('View snapshot')
|
|
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
|
|
UiEnforcement::forTableAction(
|
|
Actions\Action::make('expire')
|
|
->label('Expire snapshot')
|
|
->color('danger')
|
|
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
|
->requiresConfirmation()
|
|
->action(function (EvidenceSnapshot $record): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
app(EvidenceSnapshotService::class)->expire($record, $user);
|
|
|
|
Notification::make()->success()->title('Snapshot expired')->send();
|
|
}),
|
|
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply(),
|
|
])
|
|
->bulkActions([])
|
|
->emptyStateHeading('No evidence snapshots yet')
|
|
->emptyStateDescription('Create the first snapshot to capture immutable evidence for this tenant.')
|
|
->emptyStateActions([
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('create_first_snapshot')
|
|
->label('Create first snapshot')
|
|
->icon('heroicon-o-plus')
|
|
->action(fn (): mixed => static::executeGeneration([])),
|
|
)
|
|
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply(),
|
|
]);
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListEvidenceSnapshots::route('/'),
|
|
'view' => new PageRegistration(
|
|
page: Pages\ViewEvidenceSnapshot::class,
|
|
route: fn (Panel $panel): Route => RouteFacade::get('/{record}', Pages\ViewEvidenceSnapshot::class)
|
|
->whereNumber('record')
|
|
->middleware(Pages\ViewEvidenceSnapshot::getRouteMiddleware($panel))
|
|
->withoutMiddleware(Pages\ViewEvidenceSnapshot::getWithoutRouteMiddleware($panel)),
|
|
),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
*/
|
|
private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item): array
|
|
{
|
|
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
|
|
|
|
return match ($item->dimension_key) {
|
|
'findings_summary' => static::findingsSummaryPresentation($payload),
|
|
'permission_posture' => static::permissionPosturePresentation($payload),
|
|
'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
|
|
'baseline_drift_posture' => static::baselineDriftPosturePresentation($payload),
|
|
'operations_summary' => static::operationsSummaryPresentation($payload),
|
|
default => static::genericSummaryPresentation($payload),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
*/
|
|
private static function findingsSummaryPresentation(array $payload): array
|
|
{
|
|
$count = (int) ($payload['count'] ?? 0);
|
|
$openCount = (int) ($payload['open_count'] ?? 0);
|
|
$severityCounts = is_array($payload['severity_counts'] ?? null) ? $payload['severity_counts'] : [];
|
|
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
|
|
|
return [
|
|
'summary' => sprintf('%d findings, %d open.', $count, $openCount),
|
|
'highlights' => [
|
|
['label' => 'Findings', 'value' => (string) $count],
|
|
['label' => 'Open findings', 'value' => (string) $openCount],
|
|
['label' => 'Critical', 'value' => (string) ((int) ($severityCounts['critical'] ?? 0))],
|
|
['label' => 'High', 'value' => (string) ((int) ($severityCounts['high'] ?? 0))],
|
|
['label' => 'Medium', 'value' => (string) ((int) ($severityCounts['medium'] ?? 0))],
|
|
['label' => 'Low', 'value' => (string) ((int) ($severityCounts['low'] ?? 0))],
|
|
],
|
|
'items' => collect($entries)
|
|
->map(fn (mixed $entry): ?string => is_array($entry) ? static::findingEntryLabel($entry) : null)
|
|
->filter()
|
|
->take(5)
|
|
->values()
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
*/
|
|
private static function permissionPosturePresentation(array $payload): array
|
|
{
|
|
$requiredCount = (int) ($payload['required_count'] ?? 0);
|
|
$grantedCount = (int) ($payload['granted_count'] ?? 0);
|
|
$postureScore = $payload['posture_score'] ?? null;
|
|
$reportPayload = is_array($payload['payload'] ?? null) ? $payload['payload'] : [];
|
|
|
|
return [
|
|
'summary' => sprintf('%d of %d required permissions granted.', $grantedCount, $requiredCount),
|
|
'highlights' => [
|
|
['label' => 'Granted permissions', 'value' => (string) $grantedCount],
|
|
['label' => 'Required permissions', 'value' => (string) $requiredCount],
|
|
['label' => 'Posture score', 'value' => $postureScore === null ? '—' : (string) $postureScore],
|
|
],
|
|
'items' => static::namedItemsFromArray(
|
|
Arr::get($reportPayload, 'missing_permissions', Arr::get($reportPayload, 'missing', [])),
|
|
'No missing permission details captured.'
|
|
),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
*/
|
|
private static function entraAdminRolesPresentation(array $payload): array
|
|
{
|
|
$roleCount = (int) ($payload['role_count'] ?? 0);
|
|
|
|
return [
|
|
'summary' => sprintf('%d privileged Entra roles captured.', $roleCount),
|
|
'highlights' => [
|
|
['label' => 'Role count', 'value' => (string) $roleCount],
|
|
],
|
|
'items' => static::namedItemsFromArray($payload['roles'] ?? [], 'No role details captured.'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
*/
|
|
private static function baselineDriftPosturePresentation(array $payload): array
|
|
{
|
|
$driftCount = (int) ($payload['drift_count'] ?? 0);
|
|
$openDriftCount = (int) ($payload['open_drift_count'] ?? 0);
|
|
|
|
return [
|
|
'summary' => sprintf('%d drift findings, %d still open.', $driftCount, $openDriftCount),
|
|
'highlights' => [
|
|
['label' => 'Drift findings', 'value' => (string) $driftCount],
|
|
['label' => 'Open drift findings', 'value' => (string) $openDriftCount],
|
|
],
|
|
'items' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
*/
|
|
private static function operationsSummaryPresentation(array $payload): array
|
|
{
|
|
$operationCount = (int) ($payload['operation_count'] ?? 0);
|
|
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
|
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
|
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
|
$actionSummary = $failedCount === 0 && $partialCount === 0
|
|
? 'No action needed.'
|
|
: sprintf('%d execution failures, %d need follow-up.', $failedCount, $partialCount);
|
|
|
|
return [
|
|
'summary' => sprintf('%d operations in the last 30 days. %s', $operationCount, $actionSummary),
|
|
'highlights' => [
|
|
['label' => 'Operations', 'value' => (string) $operationCount],
|
|
['label' => 'Execution failures', 'value' => (string) $failedCount],
|
|
['label' => 'Needs follow-up', 'value' => (string) $partialCount],
|
|
],
|
|
'items' => collect($entries)
|
|
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
|
|
->filter()
|
|
->take(5)
|
|
->values()
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
*/
|
|
private static function genericSummaryPresentation(array $payload): array
|
|
{
|
|
$highlights = collect($payload)
|
|
->reject(fn (mixed $value, string|int $key): bool => in_array((string) $key, ['entries', 'payload', 'roles'], true) || is_array($value))
|
|
->take(6)
|
|
->map(fn (mixed $value, string|int $key): array => [
|
|
'label' => Str::headline((string) $key),
|
|
'value' => static::stringifySummaryValue($value),
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'summary' => empty($highlights) ? 'No summary details captured.' : null,
|
|
'highlights' => $highlights,
|
|
'items' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private static function namedItemsFromArray(mixed $items, string $emptyFallback): array
|
|
{
|
|
if (! is_array($items) || $items === []) {
|
|
return [$emptyFallback];
|
|
}
|
|
|
|
$labels = collect($items)
|
|
->map(function (mixed $item): ?string {
|
|
if (is_string($item)) {
|
|
return trim($item) !== '' ? $item : null;
|
|
}
|
|
|
|
if (! is_array($item)) {
|
|
return null;
|
|
}
|
|
|
|
foreach (['display_name', 'displayName', 'name', 'title', 'id'] as $key) {
|
|
$value = $item[$key] ?? null;
|
|
|
|
if (is_string($value) && trim($value) !== '') {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
})
|
|
->filter()
|
|
->take(5)
|
|
->values()
|
|
->all();
|
|
|
|
return $labels === [] ? [$emptyFallback] : $labels;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
*/
|
|
private static function findingEntryLabel(array $entry): ?string
|
|
{
|
|
$title = $entry['title'] ?? null;
|
|
$severity = $entry['severity'] ?? null;
|
|
$status = $entry['status'] ?? null;
|
|
|
|
if (! is_string($title) || trim($title) === '') {
|
|
return null;
|
|
}
|
|
|
|
$parts = [trim($title)];
|
|
|
|
if (is_string($severity) && trim($severity) !== '') {
|
|
$parts[] = Str::headline($severity);
|
|
}
|
|
|
|
if (is_string($status) && trim($status) !== '') {
|
|
$parts[] = Str::headline($status);
|
|
}
|
|
|
|
return implode(' · ', $parts);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
*/
|
|
private static function operationEntryLabel(array $entry): ?string
|
|
{
|
|
$type = $entry['type'] ?? null;
|
|
|
|
if (! is_string($type) || trim($type) === '') {
|
|
return null;
|
|
}
|
|
|
|
$parts = [static::operationTypeLabel($type)];
|
|
|
|
$stateLabel = static::operationEntryStateLabel($entry);
|
|
|
|
if ($stateLabel !== null) {
|
|
$parts[] = $stateLabel;
|
|
}
|
|
|
|
return implode(' · ', $parts);
|
|
}
|
|
|
|
public static function canExpireRecord(EvidenceSnapshot $record): bool
|
|
{
|
|
return (string) $record->status !== EvidenceSnapshotStatus::Expired->value;
|
|
}
|
|
|
|
private static function operationTypeLabel(string $type): string
|
|
{
|
|
$label = OperationCatalog::label($type);
|
|
|
|
return $label === 'Unknown operation' ? 'Operation' : $label;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $entry
|
|
*/
|
|
private static function operationEntryStateLabel(array $entry): ?string
|
|
{
|
|
$status = is_string($entry['status'] ?? null) ? trim((string) $entry['status']) : null;
|
|
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
|
|
|
|
return match ($status) {
|
|
OperationRunStatus::Queued->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
|
OperationRunStatus::Running->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
|
OperationRunStatus::Completed->value => match ($outcome) {
|
|
OperationRunOutcome::Pending->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
|
OperationRunOutcome::Succeeded->value,
|
|
OperationRunOutcome::PartiallySucceeded->value,
|
|
OperationRunOutcome::Blocked->value,
|
|
OperationRunOutcome::Failed->value,
|
|
OperationRunOutcome::Cancelled->value => static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome),
|
|
default => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
|
},
|
|
default => $outcome !== null ? static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome) : null,
|
|
};
|
|
}
|
|
|
|
private static function evidenceCompletenessCountLabel(string $state): string
|
|
{
|
|
return BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $state)->label;
|
|
}
|
|
|
|
private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
|
{
|
|
if ($state === null || trim($state) === '') {
|
|
return null;
|
|
}
|
|
|
|
$label = BadgeCatalog::spec($domain, $state)->label;
|
|
|
|
return $label === 'Unknown' ? null : $label;
|
|
}
|
|
|
|
private static function stringifySummaryValue(mixed $value): string
|
|
{
|
|
return match (true) {
|
|
$value === null => '—',
|
|
is_bool($value) => $value ? 'Yes' : 'No',
|
|
is_scalar($value) => (string) $value,
|
|
default => '—',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public static function executeGeneration(array $data): void
|
|
{
|
|
$tenant = Filament::getTenant();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$snapshot = app(EvidenceSnapshotService::class)->generate(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
allowStale: (bool) ($data['allow_stale'] ?? false),
|
|
);
|
|
|
|
if (! $snapshot->wasRecentlyCreated) {
|
|
Notification::make()
|
|
->success()
|
|
->title('Snapshot already available')
|
|
->body('A matching active snapshot already exists. No new run was started.')
|
|
->actions([
|
|
Actions\Action::make('view_snapshot')
|
|
->label('View snapshot')
|
|
->url(static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->success()
|
|
->title('Create snapshot queued')
|
|
->body('The snapshot is being generated in the background.')
|
|
->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url($snapshot->operation_run_id ? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id) : static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
|
])
|
|
->send();
|
|
}
|
|
}
|