## Summary - introduce a shared enterprise-detail composition layer for Filament detail pages - migrate BackupSet, BaselineSnapshot, EntraGroup, and OperationRun detail screens to the shared summary-first layout - add regression and unit coverage for section hierarchy, related context, degraded states, and duplicate fact/badge presentation ## Scope - adds shared support classes under `app/Support/Ui/EnterpriseDetail` - adds shared enterprise detail Blade partials under `resources/views/filament/infolists/entries/enterprise-detail` - updates touched Filament resources/pages to use the shared detail shell - includes Spec 133 artifacts under `specs/133-detail-page-template` ## Notes - branch: `133-detail-page-template` - base: `dev` - commit: `fd294c7` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #162
320 lines
13 KiB
PHP
320 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Baselines\SnapshotRendering;
|
|
|
|
use App\Models\BaselineSnapshot;
|
|
use App\Models\BaselineSnapshotItem;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
|
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
|
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
final class BaselineSnapshotPresenter
|
|
{
|
|
public function __construct(
|
|
private readonly SnapshotTypeRendererRegistry $registry,
|
|
) {}
|
|
|
|
public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
|
{
|
|
$snapshot->loadMissing(['baselineProfile', 'items']);
|
|
|
|
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
|
$items = $snapshot->items instanceof EloquentCollection
|
|
? $snapshot->items->sortBy([
|
|
['policy_type', 'asc'],
|
|
['id', 'asc'],
|
|
])->values()
|
|
: collect();
|
|
|
|
$groups = $items
|
|
->groupBy(static fn (BaselineSnapshotItem $item): string => (string) $item->policy_type)
|
|
->map(fn (Collection $groupItems, string $policyType): RenderedSnapshotGroup => $this->presentGroup($policyType, $groupItems))
|
|
->sortBy(static fn (RenderedSnapshotGroup $group): string => mb_strtolower($group->label))
|
|
->values()
|
|
->all();
|
|
|
|
$summaryRows = array_map(
|
|
static fn (RenderedSnapshotGroup $group): array => [
|
|
'policyType' => $group->policyType,
|
|
'label' => $group->label,
|
|
'itemCount' => $group->itemCount,
|
|
'fidelity' => $group->fidelity->value,
|
|
'gapCount' => $group->gapSummary->count,
|
|
'capturedAt' => $group->capturedAt,
|
|
'coverageHint' => $group->coverageHint,
|
|
],
|
|
$groups,
|
|
);
|
|
|
|
$overallGapCount = $this->summaryGapCount($summary);
|
|
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
|
|
|
|
return new RenderedSnapshot(
|
|
snapshotId: (int) $snapshot->getKey(),
|
|
baselineProfileName: $snapshot->baselineProfile?->name,
|
|
capturedAt: $snapshot->captured_at?->toIso8601String(),
|
|
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
|
|
? trim($snapshot->snapshot_identity_hash)
|
|
: null,
|
|
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete',
|
|
fidelitySummary: $this->fidelitySummary($summary),
|
|
overallFidelity: $overallFidelity,
|
|
overallGapCount: $overallGapCount,
|
|
summaryRows: $summaryRows,
|
|
groups: $groups,
|
|
technicalDetail: [
|
|
'defaultCollapsed' => true,
|
|
'summaryPayload' => $summary,
|
|
'groupPayloads' => array_map(
|
|
static fn (RenderedSnapshotGroup $group): array => [
|
|
'label' => $group->label,
|
|
'renderingError' => $group->renderingError,
|
|
'payload' => $group->technicalPayload,
|
|
],
|
|
$groups,
|
|
),
|
|
],
|
|
hasItems: $items->isNotEmpty(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $relatedContext
|
|
*/
|
|
public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relatedContext = []): EnterpriseDetailPageData
|
|
{
|
|
$rendered = $this->present($snapshot);
|
|
$factory = new EnterpriseDetailSectionFactory;
|
|
|
|
$stateBadge = $factory->statusBadge(
|
|
$rendered->stateLabel,
|
|
$rendered->overallGapCount > 0 ? 'warning' : 'success',
|
|
);
|
|
|
|
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
|
$fidelityBadge = $factory->statusBadge(
|
|
$fidelitySpec->label,
|
|
$fidelitySpec->color,
|
|
$fidelitySpec->icon,
|
|
$fidelitySpec->iconColor,
|
|
);
|
|
|
|
$capturedItemCount = array_sum(array_map(
|
|
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
|
$rendered->summaryRows,
|
|
));
|
|
|
|
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
|
->header(new SummaryHeaderData(
|
|
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
|
|
subtitle: 'Snapshot #'.$rendered->snapshotId,
|
|
statusBadges: [$stateBadge, $fidelityBadge],
|
|
keyFacts: [
|
|
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
|
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
|
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
|
$factory->keyFact('Captured items', $capturedItemCount),
|
|
],
|
|
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
|
))
|
|
->addSection(
|
|
$factory->viewSection(
|
|
id: 'coverage_summary',
|
|
kind: 'current_status',
|
|
title: 'Coverage summary',
|
|
view: 'filament.infolists.entries.baseline-snapshot-summary-table',
|
|
viewData: ['rows' => $rendered->summaryRows],
|
|
emptyState: $factory->emptyState('No captured policy types are available in this snapshot.'),
|
|
),
|
|
$factory->viewSection(
|
|
id: 'related_context',
|
|
kind: 'related_context',
|
|
title: 'Related context',
|
|
view: 'filament.infolists.entries.related-context',
|
|
viewData: ['entries' => $relatedContext],
|
|
emptyState: $factory->emptyState('No related context is available for this record.'),
|
|
),
|
|
$factory->viewSection(
|
|
id: 'captured_policy_types',
|
|
kind: 'domain_detail',
|
|
title: 'Captured policy types',
|
|
view: 'filament.infolists.entries.baseline-snapshot-groups',
|
|
viewData: ['groups' => array_map(
|
|
static fn (RenderedSnapshotGroup $group): array => $group->toArray(),
|
|
$rendered->groups,
|
|
)],
|
|
emptyState: $factory->emptyState('No snapshot items were captured for this baseline snapshot.'),
|
|
),
|
|
)
|
|
->addSupportingCard(
|
|
$factory->supportingFactsCard(
|
|
kind: 'status',
|
|
title: 'Snapshot status',
|
|
items: [
|
|
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
|
|
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
|
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
|
],
|
|
),
|
|
$factory->supportingFactsCard(
|
|
kind: 'timestamps',
|
|
title: 'Capture timing',
|
|
items: [
|
|
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
|
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
|
],
|
|
),
|
|
)
|
|
->addTechnicalSection(
|
|
$factory->technicalDetail(
|
|
title: 'Technical detail',
|
|
entries: [
|
|
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
|
],
|
|
description: 'Technical payloads are secondary on purpose. Use them for debugging capture fidelity and renderer fallbacks.',
|
|
view: 'filament.infolists.entries.baseline-snapshot-technical-detail',
|
|
viewData: ['technical' => $rendered->technicalDetail],
|
|
),
|
|
)
|
|
->build();
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BaselineSnapshotItem> $items
|
|
*/
|
|
private function presentGroup(string $policyType, Collection $items): RenderedSnapshotGroup
|
|
{
|
|
$renderer = $this->registry->rendererFor($policyType);
|
|
$fallbackRenderer = $this->registry->fallbackRenderer();
|
|
$renderingError = null;
|
|
$technicalPayload = $this->technicalPayload($items);
|
|
|
|
try {
|
|
$renderedItems = $items
|
|
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $renderer->render($item))
|
|
->all();
|
|
} catch (Throwable) {
|
|
$renderedItems = $items
|
|
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item))
|
|
->all();
|
|
|
|
$renderingError = 'Structured rendering failed for this policy type. Fallback metadata is shown instead.';
|
|
}
|
|
|
|
/** @var array<int, RenderedSnapshotItem> $renderedItems */
|
|
$groupFidelity = FidelityState::aggregate(array_map(
|
|
static fn (RenderedSnapshotItem $item): FidelityState => $item->fidelity,
|
|
$renderedItems,
|
|
));
|
|
|
|
$gapSummary = GapSummary::merge(array_map(
|
|
static fn (RenderedSnapshotItem $item): GapSummary => $item->gapSummary,
|
|
$renderedItems,
|
|
));
|
|
|
|
if ($renderingError !== null) {
|
|
$gapSummary = $gapSummary->withMessage($renderingError);
|
|
}
|
|
|
|
$capturedAt = collect($renderedItems)
|
|
->pluck('observedAt')
|
|
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
|
->sortDesc()
|
|
->first();
|
|
|
|
$coverageHint = $groupFidelity->coverageHint();
|
|
|
|
if ($coverageHint === null && $gapSummary->messages !== []) {
|
|
$coverageHint = $gapSummary->messages[0];
|
|
}
|
|
|
|
return new RenderedSnapshotGroup(
|
|
policyType: $policyType,
|
|
label: $this->typeLabel($policyType),
|
|
itemCount: $items->count(),
|
|
fidelity: $groupFidelity,
|
|
gapSummary: $gapSummary,
|
|
initiallyCollapsed: true,
|
|
items: $renderedItems,
|
|
renderingError: $renderingError,
|
|
coverageHint: $coverageHint,
|
|
capturedAt: is_string($capturedAt) ? $capturedAt : null,
|
|
technicalPayload: $technicalPayload,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BaselineSnapshotItem> $items
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function technicalPayload(Collection $items): array
|
|
{
|
|
return [
|
|
'items' => $items
|
|
->map(static fn (BaselineSnapshotItem $item): array => [
|
|
'snapshot_item_id' => (int) $item->getKey(),
|
|
'policy_type' => (string) $item->policy_type,
|
|
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
|
|
])
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summary
|
|
*/
|
|
private function summaryGapCount(array $summary): int
|
|
{
|
|
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
|
$count = $gaps['count'] ?? 0;
|
|
|
|
return is_numeric($count) ? (int) $count : 0;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $summary
|
|
*/
|
|
private function fidelitySummary(array $summary): string
|
|
{
|
|
$counts = is_array($summary['fidelity_counts'] ?? null)
|
|
? $summary['fidelity_counts']
|
|
: [];
|
|
|
|
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
|
|
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
|
|
|
|
return sprintf('Content %d, Meta %d', $content, $meta);
|
|
}
|
|
|
|
private function typeLabel(string $policyType): string
|
|
{
|
|
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
|
?? InventoryPolicyTypeMeta::label($policyType)
|
|
?? Str::headline($policyType);
|
|
}
|
|
|
|
private function formatTimestamp(?string $value): string
|
|
{
|
|
if ($value === null || trim($value) === '') {
|
|
return '—';
|
|
}
|
|
|
|
try {
|
|
return Carbon::parse($value)->toDayDateTimeString();
|
|
} catch (Throwable) {
|
|
return $value;
|
|
}
|
|
}
|
|
}
|