TenantAtlas/app/Support/Baselines/BaselineCompareStats.php
2026-03-01 03:23:39 +01:00

272 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
final class BaselineCompareStats
{
/**
* @param array<string, int> $severityCounts
*/
private function __construct(
public readonly string $state,
public readonly ?string $message,
public readonly ?string $profileName,
public readonly ?int $profileId,
public readonly ?int $snapshotId,
public readonly ?int $operationRunId,
public readonly ?int $findingsCount,
public readonly array $severityCounts,
public readonly ?string $lastComparedHuman,
public readonly ?string $lastComparedIso,
public readonly ?string $failureReason,
) {}
public static function forTenant(?Tenant $tenant): self
{
if (! $tenant instanceof Tenant) {
return self::empty('no_tenant', 'No tenant selected.');
}
$assignment = BaselineTenantAssignment::query()
->where('tenant_id', $tenant->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
return self::empty(
'no_assignment',
'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.',
);
}
$profile = $assignment->baselineProfile;
if (! $profile instanceof BaselineProfile) {
return self::empty(
'no_assignment',
'The assigned baseline profile no longer exists.',
);
}
$profileName = (string) $profile->name;
$profileId = (int) $profile->getKey();
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
if ($snapshotId === null) {
return self::empty(
'no_snapshot',
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
profileName: $profileName,
profileId: $profileId,
);
}
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'baseline_compare')
->latest('id')
->first();
// Active run (queued/running)
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
return new self(
state: 'comparing',
message: 'A baseline comparison is currently in progress.',
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
lastComparedHuman: null,
lastComparedIso: null,
failureReason: null,
);
}
// Failed run — explicit error state
if ($latestRun instanceof OperationRun && $latestRun->outcome === 'failed') {
$failureSummary = is_array($latestRun->failure_summary) ? $latestRun->failure_summary : [];
$failureReason = $failureSummary['message']
?? $failureSummary['reason']
?? 'The comparison job failed. Check the run details for more information.';
return new self(
state: 'failed',
message: (string) $failureReason,
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
lastComparedIso: $latestRun->finished_at?->toIso8601String(),
failureReason: (string) $failureReason,
);
}
$lastComparedHuman = null;
$lastComparedIso = null;
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
$lastComparedHuman = $latestRun->finished_at->diffForHumans();
$lastComparedIso = $latestRun->finished_at->toIso8601String();
}
$scopeKey = 'baseline_profile:'.$profile->getKey();
// Single grouped query instead of 4 separate COUNT queries
$severityRows = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->whereIn('status', Finding::openStatusesForQuery())
->selectRaw('severity, count(*) as cnt')
->groupBy('severity')
->pluck('cnt', 'severity');
$totalFindings = (int) $severityRows->sum();
$severityCounts = [
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
];
if ($totalFindings > 0) {
return new self(
state: 'ready',
message: null,
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: $severityCounts,
lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso,
failureReason: null,
);
}
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
return new self(
state: 'ready',
message: 'No open drift findings for this baseline comparison. The tenant matches the baseline.',
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: (int) $latestRun->getKey(),
findingsCount: 0,
severityCounts: $severityCounts,
lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso,
failureReason: null,
);
}
return new self(
state: 'idle',
message: 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.',
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
operationRunId: null,
findingsCount: null,
severityCounts: $severityCounts,
lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso,
failureReason: null,
);
}
/**
* Create a DTO for widget consumption (only open/new findings).
*/
public static function forWidget(?Tenant $tenant): self
{
if (! $tenant instanceof Tenant) {
return self::empty('no_tenant', null);
}
$assignment = BaselineTenantAssignment::query()
->where('tenant_id', $tenant->getKey())
->with('baselineProfile')
->first();
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
return self::empty('no_assignment', null);
}
$profile = $assignment->baselineProfile;
$scopeKey = 'baseline_profile:'.$profile->getKey();
$severityRows = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->where('status', Finding::STATUS_NEW)
->selectRaw('severity, count(*) as cnt')
->groupBy('severity')
->pluck('cnt', 'severity');
$totalFindings = (int) $severityRows->sum();
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'baseline_compare')
->where('context->baseline_profile_id', (string) $profile->getKey())
->whereNotNull('completed_at')
->latest('completed_at')
->first();
return new self(
state: $totalFindings > 0 ? 'ready' : 'idle',
message: null,
profileName: (string) $profile->name,
profileId: (int) $profile->getKey(),
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: [
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
],
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
failureReason: null,
);
}
private static function empty(
string $state,
?string $message,
?string $profileName = null,
?int $profileId = null,
): self {
return new self(
state: $state,
message: $message,
profileName: $profileName,
profileId: $profileId,
snapshotId: null,
operationRunId: null,
findingsCount: null,
severityCounts: [],
lastComparedHuman: null,
lastComparedIso: null,
failureReason: null,
);
}
}