## Summary - standardize the shared verification report family across operation detail, onboarding, and tenant verification widget hosts - standardize normalized settings and normalized diff family wrappers across policy, policy version, and finding detail hosts - add parity and guard coverage plus the full Spec 197 artifacts, including recorded manual smoke evidence ## Testing - focused Sail regression pack from `specs/197-shared-detail-contract/quickstart.md` - local integrated-browser manual smoke for SC-197-003 and SC-197-004 Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #237
375 lines
14 KiB
PHP
375 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Filament\Support;
|
||
|
||
use App\Models\OperationRun;
|
||
use App\Support\Badges\BadgeDomain;
|
||
use App\Support\Badges\BadgeRenderer;
|
||
use App\Support\RedactionIntegrity;
|
||
use App\Support\Verification\VerificationReportFingerprint;
|
||
use App\Support\Verification\VerificationReportSanitizer;
|
||
use App\Support\Verification\VerificationReportSchema;
|
||
|
||
final class VerificationReportViewer
|
||
{
|
||
/**
|
||
* @return array<string, mixed>|null
|
||
*/
|
||
public static function report(OperationRun $run): ?array
|
||
{
|
||
$context = is_array($run->context) ? $run->context : [];
|
||
$report = $context['verification_report'] ?? null;
|
||
|
||
if (! is_array($report)) {
|
||
return null;
|
||
}
|
||
|
||
$report = VerificationReportSanitizer::sanitizeReport($report);
|
||
|
||
if (! VerificationReportSchema::isValidReport($report)) {
|
||
return null;
|
||
}
|
||
|
||
return $report;
|
||
}
|
||
|
||
public static function previousReportId(array $report): ?int
|
||
{
|
||
$previousReportId = $report['previous_report_id'] ?? null;
|
||
|
||
if (is_int($previousReportId) && $previousReportId > 0) {
|
||
return $previousReportId;
|
||
}
|
||
|
||
if (is_string($previousReportId) && ctype_digit(trim($previousReportId))) {
|
||
return (int) trim($previousReportId);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
public static function fingerprint(array $report): ?string
|
||
{
|
||
$fingerprint = $report['fingerprint'] ?? null;
|
||
|
||
if (is_string($fingerprint)) {
|
||
$fingerprint = strtolower(trim($fingerprint));
|
||
|
||
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
|
||
return $fingerprint;
|
||
}
|
||
}
|
||
|
||
return VerificationReportFingerprint::forReport($report);
|
||
}
|
||
|
||
public static function previousRun(OperationRun $run, array $report): ?OperationRun
|
||
{
|
||
$previousReportId = self::previousReportId($report);
|
||
|
||
if ($previousReportId === null) {
|
||
return null;
|
||
}
|
||
|
||
$previous = OperationRun::query()
|
||
->whereKey($previousReportId)
|
||
->where('tenant_id', (int) $run->tenant_id)
|
||
->where('workspace_id', (int) $run->workspace_id)
|
||
->first();
|
||
|
||
return $previous instanceof OperationRun ? $previous : null;
|
||
}
|
||
|
||
public static function shouldRenderForRun(OperationRun $run): bool
|
||
{
|
||
$context = is_array($run->context) ? $run->context : [];
|
||
|
||
if (array_key_exists('verification_report', $context)) {
|
||
return true;
|
||
}
|
||
|
||
return in_array((string) $run->type, ['provider.connection.check'], true);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array<string, mixed>> $acknowledgements
|
||
* @param array{
|
||
* hostKind?: string,
|
||
* changeIndicator?: array{state: string, previous_report_id: int}|null,
|
||
* previousRunUrl?: string|null,
|
||
* nextStepPlacement?: 'shared_zone'|'host_action_zone',
|
||
* hostActions?: array<int, array{kind: string, label: string, ownedByHost: bool}>,
|
||
* hostVariation?: array{
|
||
* ownsNoRunState?: bool,
|
||
* ownsActiveState?: bool,
|
||
* supportsAssist?: bool,
|
||
* supportsAcknowledge?: bool,
|
||
* supportsTechnicalDetailsTrigger?: bool
|
||
* },
|
||
* optionalZones?: array<int, string>
|
||
* } $options
|
||
* @return array<string, mixed>
|
||
*/
|
||
public static function surface(OperationRun $run, array $acknowledgements = [], array $options = []): array
|
||
{
|
||
$report = self::report($run);
|
||
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : [];
|
||
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
|
||
$groupedChecks = self::groupedChecks($report, $acknowledgements);
|
||
$changeIndicator = $options['changeIndicator'] ?? null;
|
||
$hostKind = is_string($options['hostKind'] ?? null) && $options['hostKind'] !== ''
|
||
? (string) $options['hostKind']
|
||
: 'operation_run_detail';
|
||
$nextStepPlacement = ($options['nextStepPlacement'] ?? 'shared_zone') === 'host_action_zone'
|
||
? 'host_action_zone'
|
||
: 'shared_zone';
|
||
|
||
$hostActions = collect($options['hostActions'] ?? [])
|
||
->filter(static fn (mixed $action): bool => is_array($action))
|
||
->map(static function (array $action): array {
|
||
$kind = is_string($action['kind'] ?? null) ? trim((string) $action['kind']) : 'navigation';
|
||
$label = is_string($action['label'] ?? null) ? trim((string) $action['label']) : 'Action';
|
||
|
||
return [
|
||
'kind' => $kind !== '' ? $kind : 'navigation',
|
||
'label' => $label !== '' ? $label : 'Action',
|
||
'ownedByHost' => (bool) ($action['ownedByHost'] ?? true),
|
||
];
|
||
})
|
||
->values()
|
||
->all();
|
||
|
||
$hostVariation = [
|
||
'ownsNoRunState' => (bool) (($options['hostVariation']['ownsNoRunState'] ?? false)),
|
||
'ownsActiveState' => (bool) (($options['hostVariation']['ownsActiveState'] ?? false)),
|
||
'supportsAssist' => (bool) (($options['hostVariation']['supportsAssist'] ?? false)),
|
||
'supportsAcknowledge' => (bool) (($options['hostVariation']['supportsAcknowledge'] ?? false)),
|
||
'supportsTechnicalDetailsTrigger' => (bool) (($options['hostVariation']['supportsTechnicalDetailsTrigger'] ?? false)),
|
||
];
|
||
|
||
$optionalZones = collect($options['optionalZones'] ?? ['technical_details', 'change_indicator', 'previous_run_context'])
|
||
->filter(static fn (mixed $zone): bool => is_string($zone) && trim($zone) !== '')
|
||
->map(static fn (string $zone): string => trim($zone))
|
||
->values()
|
||
->all();
|
||
|
||
$overall = $summary['overall'] ?? null;
|
||
$overallSpec = BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall);
|
||
|
||
return [
|
||
'hostKind' => $hostKind,
|
||
'coreState' => $report === null ? 'unavailable' : 'completed',
|
||
'summary' => [
|
||
'overall' => $overall,
|
||
'overallLabel' => $overallSpec->label,
|
||
'counts' => [
|
||
'total' => (int) ($counts['total'] ?? 0),
|
||
'pass' => (int) ($counts['pass'] ?? 0),
|
||
'fail' => (int) ($counts['fail'] ?? 0),
|
||
'warn' => (int) ($counts['warn'] ?? 0),
|
||
'skip' => (int) ($counts['skip'] ?? 0),
|
||
'running' => (int) ($counts['running'] ?? 0),
|
||
],
|
||
'changeIndicator' => is_array($changeIndicator) ? $changeIndicator : null,
|
||
],
|
||
'issueGroups' => $groupedChecks['issueGroups'],
|
||
'passedChecks' => $groupedChecks['passedChecks'],
|
||
'diagnostics' => [
|
||
'hasTechnicalZone' => true,
|
||
'fingerprint' => is_array($report) ? self::fingerprint($report) : null,
|
||
'previousRunUrl' => is_string($options['previousRunUrl'] ?? null) && $options['previousRunUrl'] !== ''
|
||
? (string) $options['previousRunUrl']
|
||
: null,
|
||
'operationRunId' => (int) $run->getKey(),
|
||
'flow' => (string) $run->type,
|
||
'completedAt' => $run->completed_at?->toJSON(),
|
||
],
|
||
'viewZones' => [
|
||
['key' => 'issues', 'label' => 'Issues', 'defaultVisible' => true],
|
||
['key' => 'passed', 'label' => 'Passed', 'defaultVisible' => false],
|
||
],
|
||
'nextSteps' => self::nextSteps($groupedChecks['issueGroups'], $nextStepPlacement),
|
||
'hostActions' => $hostActions,
|
||
'hostVariation' => $hostVariation,
|
||
'optionalZones' => $optionalZones,
|
||
'emptyState' => $report === null
|
||
? [
|
||
'title' => 'Verification report unavailable',
|
||
'message' => 'This operation doesn’t have a report yet. If it is still running, refresh in a moment. If it already completed, start verification again.',
|
||
]
|
||
: null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $report
|
||
* @param array<string, array<string, mixed>> $acknowledgements
|
||
* @return array{issueGroups: array<int, array{label: string, checks: array<int, array<string, mixed>>, acknowledged?: bool}>, passedChecks: array<int, array<string, mixed>>}
|
||
*/
|
||
private static function groupedChecks(?array $report, array $acknowledgements): array
|
||
{
|
||
$checks = is_array($report['checks'] ?? null) ? $report['checks'] : [];
|
||
$ackByKey = [];
|
||
|
||
foreach ($acknowledgements as $checkKey => $acknowledgement) {
|
||
if (! is_string($checkKey) || $checkKey === '' || ! is_array($acknowledgement)) {
|
||
continue;
|
||
}
|
||
|
||
$ackByKey[$checkKey] = $acknowledgement;
|
||
}
|
||
|
||
$blockers = [];
|
||
$failures = [];
|
||
$warnings = [];
|
||
$acknowledgedIssues = [];
|
||
$passed = [];
|
||
|
||
foreach ($checks as $check) {
|
||
if (! is_array($check)) {
|
||
continue;
|
||
}
|
||
|
||
$key = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
|
||
|
||
if ($key === '') {
|
||
continue;
|
||
}
|
||
|
||
$status = is_string($check['status'] ?? null) ? strtolower(trim((string) $check['status'])) : '';
|
||
$blocking = (bool) ($check['blocking'] ?? false);
|
||
$normalizedCheck = self::normalizeCheck($check, $ackByKey[$key] ?? null);
|
||
|
||
if ($normalizedCheck['acknowledgement'] !== null) {
|
||
$acknowledgedIssues[] = $normalizedCheck;
|
||
|
||
continue;
|
||
}
|
||
|
||
if ($status === 'pass') {
|
||
$passed[] = $normalizedCheck;
|
||
|
||
continue;
|
||
}
|
||
|
||
if ($status === 'fail' && $blocking) {
|
||
$blockers[] = $normalizedCheck;
|
||
|
||
continue;
|
||
}
|
||
|
||
if ($status === 'fail') {
|
||
$failures[] = $normalizedCheck;
|
||
|
||
continue;
|
||
}
|
||
|
||
if ($status === 'warn') {
|
||
$warnings[] = $normalizedCheck;
|
||
}
|
||
}
|
||
|
||
$sortChecks = static fn (array $left, array $right): int => strcmp((string) ($left['key'] ?? ''), (string) ($right['key'] ?? ''));
|
||
|
||
usort($blockers, $sortChecks);
|
||
usort($failures, $sortChecks);
|
||
usort($warnings, $sortChecks);
|
||
usort($acknowledgedIssues, $sortChecks);
|
||
usort($passed, $sortChecks);
|
||
|
||
return [
|
||
'issueGroups' => array_values(array_filter([
|
||
['label' => 'Blockers', 'checks' => $blockers],
|
||
['label' => 'Failures', 'checks' => $failures],
|
||
['label' => 'Warnings', 'checks' => $warnings],
|
||
['label' => 'Acknowledged issues', 'checks' => $acknowledgedIssues, 'acknowledged' => true],
|
||
], static fn (array $group): bool => ($group['checks'] ?? []) !== [])),
|
||
'passedChecks' => $passed,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $check
|
||
* @param array<string, mixed>|null $acknowledgement
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function normalizeCheck(array $check, ?array $acknowledgement): array
|
||
{
|
||
$nextSteps = collect($check['next_steps'] ?? [])
|
||
->filter(static fn (mixed $step): bool => is_array($step))
|
||
->map(static function (array $step): array {
|
||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
||
|
||
return [
|
||
'label' => $label,
|
||
'url' => $url,
|
||
];
|
||
})
|
||
->filter(static fn (array $step): bool => $step['label'] !== '' && $step['url'] !== '')
|
||
->values()
|
||
->all();
|
||
|
||
return [
|
||
'key' => is_string($check['key'] ?? null) ? trim((string) $check['key']) : '',
|
||
'title' => is_string($check['title'] ?? null) && trim((string) $check['title']) !== ''
|
||
? trim((string) $check['title'])
|
||
: 'Check',
|
||
'message' => is_string($check['message'] ?? null) && trim((string) $check['message']) !== ''
|
||
? trim((string) $check['message'])
|
||
: null,
|
||
'status' => is_string($check['status'] ?? null) ? trim((string) $check['status']) : null,
|
||
'severity' => is_string($check['severity'] ?? null) ? trim((string) $check['severity']) : null,
|
||
'reason_code' => is_string($check['reason_code'] ?? null) ? trim((string) $check['reason_code']) : null,
|
||
'blocking' => (bool) ($check['blocking'] ?? false),
|
||
'next_steps' => $nextSteps,
|
||
'acknowledgement' => is_array($acknowledgement) ? $acknowledgement : null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array{label: string, checks: array<int, array<string, mixed>>, acknowledged?: bool}> $issueGroups
|
||
* @return array<int, array{label: string, placement: string, ownedByHost: bool, actionKind: string|null}>
|
||
*/
|
||
private static function nextSteps(array $issueGroups, string $placement): array
|
||
{
|
||
$steps = [];
|
||
|
||
foreach ($issueGroups as $group) {
|
||
foreach ($group['checks'] as $check) {
|
||
foreach ($check['next_steps'] ?? [] as $step) {
|
||
if (! is_array($step)) {
|
||
continue;
|
||
}
|
||
|
||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
||
|
||
if ($label === '' || array_key_exists($label, $steps)) {
|
||
continue;
|
||
}
|
||
|
||
$steps[$label] = [
|
||
'label' => $label,
|
||
'placement' => $placement,
|
||
'ownedByHost' => $placement === 'host_action_zone',
|
||
'actionKind' => $placement === 'host_action_zone' ? 'assist' : 'navigation',
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
return array_values($steps);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $report
|
||
* @return array<int, string>
|
||
*/
|
||
public static function redactionNotes(?array $report): array
|
||
{
|
||
return RedactionIntegrity::verificationNotes($report);
|
||
}
|
||
}
|