## Summary - align tenant dashboard KPI, attention, compare, and operations truth so the page does not read calmer than the tenant's actual state - preserve tenant-safe drill-through continuity into findings, baseline compare, and canonical operations, including disabled helper states for permission-limited members - add the Spec 173 artifact set and focused regression coverage for dashboard truth alignment and drill-through behavior ## Validation - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/BaselineCompareNowWidgetTest.php tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Findings/FindingsListDefaultsTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Findings/FindingAdminTenantParityTest.php tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Filament/TenantDashboardTenantScopeTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Feature/Filament/TableStandardsBaselineTest.php tests/Feature/Filament/TableDetailVisibilityTest.php` - integrated browser smoke on the tenant dashboard, including a permission-limited member scenario Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #204
212 lines
7.7 KiB
PHP
212 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Widgets\Dashboard;
|
|
|
|
use App\Filament\Pages\BaselineCompareLanding;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\FindingException;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Baselines\BaselineCompareSummaryAssessment;
|
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\Rbac\UiTooltips;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Widgets\Widget;
|
|
|
|
class BaselineCompareNow extends Widget
|
|
{
|
|
protected string $view = 'filament.widgets.dashboard.baseline-compare-now';
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function getViewData(): array
|
|
{
|
|
$tenant = Filament::getTenant();
|
|
|
|
$empty = [
|
|
'hasAssignment' => false,
|
|
'profileName' => null,
|
|
'lastComparedAt' => null,
|
|
'landingUrl' => null,
|
|
'runUrl' => null,
|
|
'findingsUrl' => null,
|
|
'nextActionLabel' => null,
|
|
'nextActionUrl' => null,
|
|
'nextActionHelperText' => null,
|
|
'summaryAssessment' => null,
|
|
];
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return $empty;
|
|
}
|
|
|
|
$aggregate = $this->governanceAggregate($tenant);
|
|
|
|
if ($aggregate->compareState === 'no_assignment') {
|
|
return $empty;
|
|
}
|
|
|
|
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
|
$operationsFollowUpCount = (int) OperationRun::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->dashboardNeedsFollowUp()
|
|
->count();
|
|
$summaryAssessment = $this->dashboardSummaryAssessment($aggregate, $operationsFollowUpCount);
|
|
$runUrl = $this->runUrl($tenant, $aggregate);
|
|
$findingsUrl = $this->findingsUrl($tenant, $aggregate);
|
|
$nextActionTarget = (string) ($summaryAssessment['dashboardNextActionTarget'] ?? (($summaryAssessment['nextAction']['target'] ?? 'none') ?: 'none'));
|
|
$nextActionLabel = (string) ($summaryAssessment['nextAction']['label'] ?? '');
|
|
$nextActionUrl = match ($nextActionTarget) {
|
|
'run' => $runUrl,
|
|
'findings' => $findingsUrl,
|
|
'landing' => $tenantLandingUrl,
|
|
'operations' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
|
|
default => null,
|
|
};
|
|
$nextActionHelperText = in_array($nextActionTarget, ['run', 'findings'], true) && $nextActionUrl === null
|
|
? UiTooltips::INSUFFICIENT_PERMISSION
|
|
: null;
|
|
|
|
return [
|
|
'hasAssignment' => true,
|
|
'profileName' => $aggregate->profileName,
|
|
'lastComparedAt' => $aggregate->lastComparedLabel,
|
|
'landingUrl' => $tenantLandingUrl,
|
|
'runUrl' => $runUrl,
|
|
'findingsUrl' => $findingsUrl,
|
|
'nextActionLabel' => $nextActionLabel !== '' ? $nextActionLabel : null,
|
|
'nextActionUrl' => $nextActionUrl,
|
|
'nextActionHelperText' => $nextActionHelperText,
|
|
'summaryAssessment' => $summaryAssessment,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function dashboardSummaryAssessment(TenantGovernanceAggregate $aggregate, int $operationsFollowUpCount): array
|
|
{
|
|
$summaryAssessment = $aggregate->summaryAssessment->toArray();
|
|
|
|
if (($summaryAssessment['stateFamily'] ?? null) !== BaselineCompareSummaryAssessment::STATE_POSITIVE) {
|
|
return $summaryAssessment;
|
|
}
|
|
|
|
if ($aggregate->highSeverityActiveFindingsCount > 0) {
|
|
$count = $aggregate->highSeverityActiveFindingsCount;
|
|
|
|
return array_merge($summaryAssessment, [
|
|
'stateFamily' => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
|
'tone' => 'danger',
|
|
'headline' => sprintf('%d high-severity active finding%s need review.', $count, $count === 1 ? '' : 's'),
|
|
'supportingMessage' => 'The latest compare may be healthy, but the tenant still has active high-severity findings.',
|
|
'highSeverityCount' => $count,
|
|
'nextAction' => [
|
|
'label' => 'Open findings',
|
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
|
],
|
|
'dashboardNextActionTarget' => 'findings',
|
|
]);
|
|
}
|
|
|
|
if ($operationsFollowUpCount > 0) {
|
|
return array_merge($summaryAssessment, [
|
|
'stateFamily' => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
|
'tone' => 'danger',
|
|
'headline' => sprintf('%d operation%s need follow-up.', $operationsFollowUpCount, $operationsFollowUpCount === 1 ? '' : 's'),
|
|
'supportingMessage' => 'Failed, warning, or stalled runs still need review before this tenant reads as fully calm.',
|
|
'nextAction' => [
|
|
'label' => 'Open operations',
|
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
|
],
|
|
'dashboardNextActionTarget' => 'operations',
|
|
]);
|
|
}
|
|
|
|
return $summaryAssessment;
|
|
}
|
|
|
|
private function runUrl(Tenant $tenant, TenantGovernanceAggregate $aggregate): ?string
|
|
{
|
|
$runId = $aggregate->stats->operationRunId;
|
|
|
|
if (! is_int($runId)) {
|
|
return null;
|
|
}
|
|
|
|
$run = OperationRun::query()->find($runId);
|
|
|
|
if (! $run instanceof OperationRun || ! $this->canOpenRun($run)) {
|
|
return null;
|
|
}
|
|
|
|
return OperationRunLinks::view($run, $tenant);
|
|
}
|
|
|
|
private function findingsUrl(Tenant $tenant, TenantGovernanceAggregate $aggregate): ?string
|
|
{
|
|
if (! $this->canOpenFindings($tenant)) {
|
|
return null;
|
|
}
|
|
|
|
$parameters = match (true) {
|
|
$aggregate->lapsedGovernanceCount > 0 => [
|
|
'tab' => 'risk_accepted',
|
|
'governance_validity' => FindingException::VALIDITY_MISSING_SUPPORT,
|
|
],
|
|
$aggregate->overdueOpenFindingsCount > 0 => [
|
|
'tab' => 'overdue',
|
|
],
|
|
$aggregate->expiringGovernanceCount > 0 => [
|
|
'tab' => 'risk_accepted',
|
|
'governance_validity' => FindingException::VALIDITY_EXPIRING,
|
|
],
|
|
$aggregate->highSeverityActiveFindingsCount > 0 => [
|
|
'tab' => 'needs_action',
|
|
'high_severity' => 1,
|
|
],
|
|
$aggregate->visibleDriftFindingsCount > 0 => [
|
|
'tab' => 'needs_action',
|
|
'finding_type' => 'drift',
|
|
],
|
|
default => [],
|
|
};
|
|
|
|
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
|
|
}
|
|
|
|
private function canOpenFindings(Tenant $tenant): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
return $user instanceof User
|
|
&& $user->canAccessTenant($tenant)
|
|
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
|
}
|
|
|
|
private function canOpenRun(OperationRun $run): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
return $user instanceof User && $user->can('view', $run);
|
|
}
|
|
|
|
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
|
{
|
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
|
$resolver = app(TenantGovernanceAggregateResolver::class);
|
|
|
|
/** @var TenantGovernanceAggregate $aggregate */
|
|
$aggregate = $resolver->forTenant($tenant);
|
|
|
|
return $aggregate;
|
|
}
|
|
}
|