## Summary - harden the workspace overview into a governance-aware attention surface that separates governance risk from activity and keeps calm states honest - add tenant-bound attention, workspace-wide operations continuity, and low-permission fallback behavior for workspace-originated operations drill-through - add the full Spec 175 artifact set and focused workspace overview regression coverage, plus align remaining operation-viewer wording and guard expectations so the suite stays green ## Testing - `vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAccessTest.php tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php tests/Feature/Filament/WorkspaceOverviewLandingTest.php tests/Feature/Filament/WorkspaceOverviewNavigationTest.php tests/Feature/Filament/WorkspaceOverviewContentTest.php tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php` - `vendor/bin/sail artisan test --compact tests/Unit/Support/RelatedActionLabelCatalogTest.php tests/Feature/078/VerificationReportTenantlessTest.php tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php tests/Feature/Filament/ReferencedTenantLifecyclePresentationTest.php tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php tests/Feature/Monitoring/AuditLogInspectFlowTest.php tests/Feature/Monitoring/HeaderContextBarTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/OpsUx/QueuedToastCopyTest.php tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php tests/Feature/Verification/VerificationReportRedactionTest.php` - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact` ## Notes - branch pushed as `175-workspace-governance-attention` - full suite result: `3235 passed, 8 skipped` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #206
1072 lines
41 KiB
PHP
1072 lines
41 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Workspaces;
|
|
|
|
use App\Filament\Pages\BaselineCompareLanding;
|
|
use App\Filament\Pages\ChooseTenant;
|
|
use App\Filament\Pages\TenantDashboard;
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\FindingException;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\Baselines\BaselineCompareSummaryAssessment;
|
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
|
use App\Support\Navigation\CanonicalNavigationContext;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\Rbac\UiTooltips;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Collection;
|
|
|
|
final class WorkspaceOverviewBuilder
|
|
{
|
|
public function __construct(
|
|
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
|
private CapabilityResolver $capabilityResolver,
|
|
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function build(Workspace $workspace, User $user): array
|
|
{
|
|
$workspaceId = (int) $workspace->getKey();
|
|
$accessibleTenants = $this->accessibleTenants($workspace, $user);
|
|
$accessibleTenantIds = $accessibleTenants
|
|
->pluck('id')
|
|
->map(static fn (mixed $id): int => (int) $id)
|
|
->all();
|
|
|
|
$this->capabilityResolver->primeMemberships($user, $accessibleTenantIds);
|
|
|
|
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
|
$navigationContext = $this->workspaceOverviewNavigationContext();
|
|
$tenantContexts = $this->tenantContexts($accessibleTenants, $workspaceId, $canViewAlerts);
|
|
$attentionItems = $this->attentionItems($tenantContexts, $user, $canViewAlerts, $navigationContext);
|
|
|
|
$governanceAttentionTenantCount = count(array_filter(
|
|
$tenantContexts,
|
|
static fn (array $context): bool => (bool) ($context['has_governance_attention'] ?? false),
|
|
));
|
|
|
|
$totalActiveOperationsCount = (int) $this->scopeToAuthorizedTenants(
|
|
OperationRun::query(),
|
|
$workspaceId,
|
|
$accessibleTenantIds,
|
|
)
|
|
->healthyActive()
|
|
->count();
|
|
|
|
$totalAlertFailuresCount = $canViewAlerts
|
|
? (int) $this->scopeToAuthorizedTenants(
|
|
AlertDelivery::query(),
|
|
$workspaceId,
|
|
$accessibleTenantIds,
|
|
)
|
|
->where('created_at', '>=', now()->subDays(7))
|
|
->where('status', AlertDelivery::STATUS_FAILED)
|
|
->count()
|
|
: 0;
|
|
|
|
$calmness = $this->calmnessState(
|
|
accessibleTenantCount: $accessibleTenants->count(),
|
|
attentionItems: $attentionItems,
|
|
governanceAttentionTenantCount: $governanceAttentionTenantCount,
|
|
totalActiveOperationsCount: $totalActiveOperationsCount,
|
|
totalAlertFailuresCount: $totalAlertFailuresCount,
|
|
canViewAlerts: $canViewAlerts,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
|
|
$attentionEmptyState = [
|
|
'title' => $calmness['title'],
|
|
'body' => $calmness['body'],
|
|
'action_label' => $calmness['next_action']['label'] ?? 'Choose tenant',
|
|
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
|
|
];
|
|
|
|
$zeroTenantState = null;
|
|
|
|
if ($accessibleTenants->isEmpty()) {
|
|
$zeroTenantState = $attentionEmptyState;
|
|
}
|
|
|
|
$summaryMetrics = $this->summaryMetrics(
|
|
accessibleTenantCount: $accessibleTenants->count(),
|
|
governanceAttentionTenantCount: $governanceAttentionTenantCount,
|
|
totalActiveOperationsCount: $totalActiveOperationsCount,
|
|
totalAlertFailuresCount: $totalAlertFailuresCount,
|
|
canViewAlerts: $canViewAlerts,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
|
|
$recentOperations = $this->recentOperations($workspaceId, $accessibleTenantIds, $navigationContext);
|
|
$quickActions = $this->quickActions(
|
|
workspace: $workspace,
|
|
accessibleTenantCount: $accessibleTenants->count(),
|
|
canViewAlerts: $canViewAlerts,
|
|
user: $user,
|
|
navigationContext: $navigationContext,
|
|
);
|
|
|
|
return [
|
|
'workspace' => [
|
|
'id' => $workspaceId,
|
|
'name' => (string) $workspace->name,
|
|
'slug' => filled($workspace->slug) ? (string) $workspace->slug : null,
|
|
],
|
|
'workspace_id' => $workspaceId,
|
|
'workspace_name' => (string) $workspace->name,
|
|
'accessible_tenant_count' => $accessibleTenants->count(),
|
|
'summary_metrics' => $summaryMetrics,
|
|
'attention_items' => $attentionItems,
|
|
'attention_empty_state' => $attentionEmptyState,
|
|
'recent_operations' => $recentOperations,
|
|
'recent_operations_empty_state' => [
|
|
'title' => 'No recent operations yet',
|
|
'body' => 'Diagnostic execution context will appear here once runs start. This section does not define workspace health on its own.',
|
|
'action_label' => 'Open operations',
|
|
'action_url' => OperationRunLinks::index(context: $navigationContext, allTenants: true),
|
|
],
|
|
'quick_actions' => $quickActions,
|
|
'zero_tenant_state' => $zeroTenantState,
|
|
'calmness' => $calmness,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, Tenant>
|
|
*/
|
|
private function accessibleTenants(Workspace $workspace, User $user): Collection
|
|
{
|
|
return Tenant::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('status', 'active')
|
|
->whereIn('id', $user->tenantMemberships()->select('tenant_id'))
|
|
->orderBy('name')
|
|
->get(['id', 'name', 'external_id', 'workspace_id']);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, Tenant> $accessibleTenants
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function tenantContexts(Collection $accessibleTenants, int $workspaceId, bool $canViewAlerts): array
|
|
{
|
|
$accessibleTenantIds = $accessibleTenants
|
|
->pluck('id')
|
|
->map(static fn (mixed $id): int => (int) $id)
|
|
->all();
|
|
|
|
if ($accessibleTenantIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$followUpRuns = $this->scopeToVisibleTenants(
|
|
OperationRun::query()->with('tenant'),
|
|
$workspaceId,
|
|
$accessibleTenantIds,
|
|
)
|
|
->dashboardNeedsFollowUp()
|
|
->latest('created_at')
|
|
->get()
|
|
->groupBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
|
|
|
|
$followUpCounts = $followUpRuns
|
|
->map(static fn (Collection $runs): int => $runs->count())
|
|
->all();
|
|
|
|
$activeOperationCounts = $this->scopeToVisibleTenants(
|
|
OperationRun::query(),
|
|
$workspaceId,
|
|
$accessibleTenantIds,
|
|
)
|
|
->healthyActive()
|
|
->selectRaw('tenant_id, count(*) as aggregate_count')
|
|
->groupBy('tenant_id')
|
|
->pluck('aggregate_count', 'tenant_id')
|
|
->map(static fn (mixed $count): int => (int) $count)
|
|
->all();
|
|
|
|
$alertFailureCounts = $canViewAlerts
|
|
? $this->scopeToVisibleTenants(
|
|
AlertDelivery::query(),
|
|
$workspaceId,
|
|
$accessibleTenantIds,
|
|
)
|
|
->where('created_at', '>=', now()->subDays(7))
|
|
->where('status', AlertDelivery::STATUS_FAILED)
|
|
->selectRaw('tenant_id, count(*) as aggregate_count')
|
|
->groupBy('tenant_id')
|
|
->pluck('aggregate_count', 'tenant_id')
|
|
->map(static fn (mixed $count): int => (int) $count)
|
|
->all()
|
|
: [];
|
|
|
|
return $accessibleTenants
|
|
->map(function (Tenant $tenant) use ($followUpCounts, $followUpRuns, $activeOperationCounts, $alertFailureCounts): array {
|
|
$tenantId = (int) $tenant->getKey();
|
|
$aggregate = $this->governanceAggregate($tenant);
|
|
|
|
return [
|
|
'tenant' => $tenant,
|
|
'aggregate' => $aggregate,
|
|
'has_governance_attention' => $this->hasGovernanceAttention($aggregate),
|
|
'follow_up_operations_count' => (int) ($followUpCounts[$tenantId] ?? 0),
|
|
'latest_follow_up_run' => $followUpRuns->get($tenantId)?->first(),
|
|
'active_operations_count' => (int) ($activeOperationCounts[$tenantId] ?? 0),
|
|
'alert_failures_count' => (int) ($alertFailureCounts[$tenantId] ?? 0),
|
|
];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
|
{
|
|
/** @var TenantGovernanceAggregate $aggregate */
|
|
$aggregate = $this->tenantGovernanceAggregateResolver->forTenant($tenant);
|
|
|
|
return $aggregate;
|
|
}
|
|
|
|
private function hasGovernanceAttention(TenantGovernanceAggregate $aggregate): bool
|
|
{
|
|
if (
|
|
$aggregate->lapsedGovernanceCount > 0
|
|
|| $aggregate->overdueOpenFindingsCount > 0
|
|
|| $aggregate->expiringGovernanceCount > 0
|
|
|| $aggregate->highSeverityActiveFindingsCount > 0
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return $this->shouldPromoteCompareAttention($aggregate);
|
|
}
|
|
|
|
private function shouldPromoteCompareAttention(TenantGovernanceAggregate $aggregate): bool
|
|
{
|
|
if ($aggregate->summaryAssessment->stateFamily === BaselineCompareSummaryAssessment::STATE_STALE) {
|
|
return true;
|
|
}
|
|
|
|
return $aggregate->summaryAssessment->stateFamily === BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED
|
|
&& in_array($aggregate->nextActionTarget, [
|
|
BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
|
BaselineCompareSummaryAssessment::NEXT_TARGET_RUN,
|
|
], true);
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $tenantContexts
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function attentionItems(
|
|
array $tenantContexts,
|
|
User $user,
|
|
bool $canViewAlerts,
|
|
CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$items = collect($tenantContexts)
|
|
->map(function (array $context) use ($user, $canViewAlerts, $navigationContext): ?array {
|
|
$tenant = $context['tenant'] ?? null;
|
|
$aggregate = $context['aggregate'] ?? null;
|
|
|
|
if (! $tenant instanceof Tenant || ! $aggregate instanceof TenantGovernanceAggregate) {
|
|
return null;
|
|
}
|
|
|
|
if ($aggregate->lapsedGovernanceCount > 0) {
|
|
return $this->makeAttentionItem(
|
|
tenant: $tenant,
|
|
key: 'tenant_lapsed_governance',
|
|
family: 'governance',
|
|
urgency: 'critical',
|
|
title: 'Lapsed accepted-risk governance',
|
|
body: sprintf(
|
|
'%d accepted-risk finding%s no longer have valid governance backing.',
|
|
$aggregate->lapsedGovernanceCount,
|
|
$aggregate->lapsedGovernanceCount === 1 ? '' : 's',
|
|
),
|
|
badge: 'Governance',
|
|
badgeColor: 'danger',
|
|
destination: $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'),
|
|
supportingMessage: 'Open the tenant dashboard to review the full invalid-governance family without narrowing the findings set.',
|
|
);
|
|
}
|
|
|
|
if ($aggregate->overdueOpenFindingsCount > 0) {
|
|
return $this->makeAttentionItem(
|
|
tenant: $tenant,
|
|
key: 'tenant_overdue_findings',
|
|
family: 'findings',
|
|
urgency: 'critical',
|
|
title: 'Overdue findings',
|
|
body: sprintf(
|
|
'%d open finding%s are overdue and still need workflow follow-up.',
|
|
$aggregate->overdueOpenFindingsCount,
|
|
$aggregate->overdueOpenFindingsCount === 1 ? '' : 's',
|
|
),
|
|
badge: 'Findings',
|
|
badgeColor: 'danger',
|
|
destination: $this->findingsTarget(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
filters: ['tab' => 'overdue'],
|
|
),
|
|
);
|
|
}
|
|
|
|
if ($this->shouldPromoteCompareAttention($aggregate)) {
|
|
return $this->makeAttentionItem(
|
|
tenant: $tenant,
|
|
key: 'tenant_compare_attention',
|
|
family: 'compare',
|
|
urgency: $aggregate->summaryAssessment->stateFamily === BaselineCompareSummaryAssessment::STATE_STALE ? 'high' : 'critical',
|
|
title: 'Baseline compare posture',
|
|
body: $aggregate->headline,
|
|
badge: 'Baseline',
|
|
badgeColor: $aggregate->tone,
|
|
destination: $this->baselineCompareTarget($tenant, $user),
|
|
supportingMessage: $aggregate->supportingMessage,
|
|
);
|
|
}
|
|
|
|
if ($aggregate->highSeverityActiveFindingsCount > 0) {
|
|
return $this->makeAttentionItem(
|
|
tenant: $tenant,
|
|
key: 'tenant_high_severity_findings',
|
|
family: 'findings',
|
|
urgency: 'high',
|
|
title: 'High severity active findings',
|
|
body: sprintf(
|
|
'%d high or critical finding%s are still active.',
|
|
$aggregate->highSeverityActiveFindingsCount,
|
|
$aggregate->highSeverityActiveFindingsCount === 1 ? '' : 's',
|
|
),
|
|
badge: 'Findings',
|
|
badgeColor: 'danger',
|
|
destination: $this->findingsTarget(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
filters: ['tab' => 'needs_action', 'high_severity' => true],
|
|
),
|
|
);
|
|
}
|
|
|
|
if ($aggregate->expiringGovernanceCount > 0) {
|
|
return $this->makeAttentionItem(
|
|
tenant: $tenant,
|
|
key: 'tenant_expiring_governance',
|
|
family: 'governance',
|
|
urgency: 'medium',
|
|
title: 'Expiring accepted-risk governance',
|
|
body: sprintf(
|
|
'%d accepted-risk finding%s need governance review soon.',
|
|
$aggregate->expiringGovernanceCount,
|
|
$aggregate->expiringGovernanceCount === 1 ? '' : 's',
|
|
),
|
|
badge: 'Governance',
|
|
badgeColor: 'warning',
|
|
destination: $this->findingsTarget(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
filters: [
|
|
'tab' => 'risk_accepted',
|
|
'governance_validity' => FindingException::VALIDITY_EXPIRING,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
$followUpOperationsCount = (int) ($context['follow_up_operations_count'] ?? 0);
|
|
|
|
if ($followUpOperationsCount > 0) {
|
|
return $this->makeAttentionItem(
|
|
tenant: $tenant,
|
|
key: 'tenant_operations_follow_up',
|
|
family: 'operations',
|
|
urgency: 'medium',
|
|
title: 'Operations need follow-up',
|
|
body: sprintf(
|
|
'%d run%s failed, completed with warnings, or still need operator follow-up.',
|
|
$followUpOperationsCount,
|
|
$followUpOperationsCount === 1 ? '' : 's',
|
|
),
|
|
badge: 'Operations',
|
|
badgeColor: 'danger',
|
|
destination: $this->operationsIndexTarget($tenant, $navigationContext, 'blocked'),
|
|
);
|
|
}
|
|
|
|
$activeOperationsCount = (int) ($context['active_operations_count'] ?? 0);
|
|
|
|
if ($activeOperationsCount > 0) {
|
|
return $this->makeAttentionItem(
|
|
tenant: $tenant,
|
|
key: 'tenant_active_operations',
|
|
family: 'operations',
|
|
urgency: 'supporting',
|
|
title: 'Operations are running',
|
|
body: sprintf(
|
|
'%d run%s are currently queued or in progress for this tenant.',
|
|
$activeOperationsCount,
|
|
$activeOperationsCount === 1 ? '' : 's',
|
|
),
|
|
badge: 'Operations',
|
|
badgeColor: 'warning',
|
|
destination: $this->operationsIndexTarget($tenant, $navigationContext, 'active'),
|
|
);
|
|
}
|
|
|
|
$alertFailuresCount = (int) ($context['alert_failures_count'] ?? 0);
|
|
|
|
if ($canViewAlerts && $alertFailuresCount > 0) {
|
|
return $this->makeAttentionItem(
|
|
tenant: $tenant,
|
|
key: 'tenant_alert_delivery_failures',
|
|
family: 'alerts',
|
|
urgency: 'supporting',
|
|
title: 'Alert deliveries failed',
|
|
body: sprintf(
|
|
'%d alert delivery attempt%s failed in the last 7 days for this tenant.',
|
|
$alertFailuresCount,
|
|
$alertFailuresCount === 1 ? '' : 's',
|
|
),
|
|
badge: 'Alerts',
|
|
badgeColor: 'danger',
|
|
destination: $this->alertsOverviewTarget($navigationContext, true),
|
|
);
|
|
}
|
|
|
|
return null;
|
|
})
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
usort($items, function (array $left, array $right): int {
|
|
$leftPriority = $this->attentionPriority($left);
|
|
$rightPriority = $this->attentionPriority($right);
|
|
|
|
if ($leftPriority !== $rightPriority) {
|
|
return $rightPriority <=> $leftPriority;
|
|
}
|
|
|
|
return strcmp((string) ($left['tenant_label'] ?? ''), (string) ($right['tenant_label'] ?? ''));
|
|
});
|
|
|
|
return array_slice($items, 0, 5);
|
|
}
|
|
|
|
private function attentionPriority(array $item): int
|
|
{
|
|
return match ($item['key'] ?? null) {
|
|
'tenant_lapsed_governance' => 100,
|
|
'tenant_overdue_findings' => 95,
|
|
'tenant_compare_attention' => 90,
|
|
'tenant_high_severity_findings' => 80,
|
|
'tenant_expiring_governance' => 70,
|
|
'tenant_operations_follow_up' => 40,
|
|
'tenant_active_operations' => 20,
|
|
'tenant_alert_delivery_failures' => 10,
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function makeAttentionItem(
|
|
Tenant $tenant,
|
|
string $key,
|
|
string $family,
|
|
string $urgency,
|
|
string $title,
|
|
string $body,
|
|
string $badge,
|
|
string $badgeColor,
|
|
array $destination,
|
|
?string $supportingMessage = null,
|
|
): array {
|
|
$item = [
|
|
'key' => $key,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'tenant_label' => (string) $tenant->name,
|
|
'tenant_route_key' => $this->tenantRouteKey($tenant),
|
|
'family' => $family,
|
|
'urgency' => $urgency,
|
|
'title' => $title,
|
|
'body' => $body,
|
|
'supporting_message' => $supportingMessage,
|
|
'badge' => $badge,
|
|
'badge_color' => $badgeColor,
|
|
'destination' => $destination,
|
|
'action_disabled' => (bool) ($destination['disabled'] ?? false),
|
|
'helper_text' => $destination['helper_text'] ?? null,
|
|
];
|
|
|
|
$item['url'] = $destination['disabled'] === true ? null : ($destination['url'] ?? null);
|
|
|
|
return $item;
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function summaryMetrics(
|
|
int $accessibleTenantCount,
|
|
int $governanceAttentionTenantCount,
|
|
int $totalActiveOperationsCount,
|
|
int $totalAlertFailuresCount,
|
|
bool $canViewAlerts,
|
|
CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$metrics = [
|
|
$this->makeSummaryMetric(
|
|
key: 'accessible_tenants',
|
|
label: 'Accessible tenants',
|
|
value: $accessibleTenantCount,
|
|
category: 'scope',
|
|
description: $accessibleTenantCount > 0
|
|
? 'Tenant drill-down stays explicit from this workspace home.'
|
|
: 'No tenant memberships are available in this workspace yet.',
|
|
color: $accessibleTenantCount > 0 ? 'primary' : 'warning',
|
|
destination: $accessibleTenantCount > 0
|
|
? $this->chooseTenantTarget()
|
|
: $this->switchWorkspaceTarget(),
|
|
),
|
|
$this->makeSummaryMetric(
|
|
key: 'governance_attention_tenants',
|
|
label: 'Governance attention',
|
|
value: $governanceAttentionTenantCount,
|
|
category: 'governance_risk',
|
|
description: 'Affected visible tenants with overdue findings, governance expiry, lapsed governance, or compare posture that needs review.',
|
|
color: $governanceAttentionTenantCount > 0 ? 'danger' : 'gray',
|
|
destination: $governanceAttentionTenantCount > 0
|
|
? $this->chooseTenantTarget('Choose tenant')
|
|
: null,
|
|
),
|
|
$this->makeSummaryMetric(
|
|
key: 'active_operations',
|
|
label: 'Active operations',
|
|
value: $totalActiveOperationsCount,
|
|
category: 'activity',
|
|
description: 'Activity only. Active execution does not imply governance health.',
|
|
color: $totalActiveOperationsCount > 0 ? 'warning' : 'gray',
|
|
destination: $totalActiveOperationsCount > 0
|
|
? $this->operationsIndexTarget(null, $navigationContext, 'active')
|
|
: null,
|
|
),
|
|
];
|
|
|
|
if ($canViewAlerts) {
|
|
$metrics[] = $this->makeSummaryMetric(
|
|
key: 'alert_failures',
|
|
label: 'Alert failures (7d)',
|
|
value: $totalAlertFailuresCount,
|
|
category: 'alerts',
|
|
description: 'Alert delivery follow-up for the visible workspace slice in the last 7 days.',
|
|
color: $totalAlertFailuresCount > 0 ? 'danger' : 'gray',
|
|
destination: $totalAlertFailuresCount > 0
|
|
? $this->alertsOverviewTarget($navigationContext, true)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
return $metrics;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function makeSummaryMetric(
|
|
string $key,
|
|
string $label,
|
|
int $value,
|
|
string $category,
|
|
string $description,
|
|
string $color,
|
|
?array $destination,
|
|
): array {
|
|
return [
|
|
'key' => $key,
|
|
'label' => $label,
|
|
'value' => $value,
|
|
'category' => $category,
|
|
'description' => $description,
|
|
'color' => $color,
|
|
'destination' => $destination,
|
|
'destination_url' => $destination !== null && ($destination['disabled'] ?? false) === false
|
|
? ($destination['url'] ?? null)
|
|
: null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $accessibleTenantIds
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function recentOperations(
|
|
int $workspaceId,
|
|
array $accessibleTenantIds,
|
|
CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$statusSpec = BadgeRenderer::label(BadgeDomain::OperationRunStatus);
|
|
$statusColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunStatus);
|
|
$outcomeSpec = BadgeRenderer::label(BadgeDomain::OperationRunOutcome);
|
|
$outcomeColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunOutcome);
|
|
|
|
return $this->scopeToAuthorizedTenants(
|
|
OperationRun::query()->with('tenant'),
|
|
$workspaceId,
|
|
$accessibleTenantIds,
|
|
)
|
|
->latest('created_at')
|
|
->limit(5)
|
|
->get()
|
|
->map(function (OperationRun $run) use ($navigationContext, $statusSpec, $statusColorSpec, $outcomeSpec, $outcomeColorSpec): array {
|
|
$destination = $this->operationDetailTarget($run, $navigationContext);
|
|
|
|
return [
|
|
'id' => (int) $run->getKey(),
|
|
'title' => OperationCatalog::label((string) $run->type),
|
|
'tenant_label' => $run->tenant instanceof Tenant ? (string) $run->tenant->name : null,
|
|
'status_label' => $statusSpec($run->status),
|
|
'status_color' => $statusColorSpec($run->status),
|
|
'outcome_label' => $outcomeSpec($run->outcome),
|
|
'outcome_color' => $outcomeColorSpec($run->outcome),
|
|
'guidance' => OperationUxPresenter::surfaceGuidance($run),
|
|
'started_at' => $run->created_at?->diffForHumans() ?? 'just now',
|
|
'destination' => $destination,
|
|
'url' => $destination['url'],
|
|
];
|
|
})
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function calmnessState(
|
|
int $accessibleTenantCount,
|
|
array $attentionItems,
|
|
int $governanceAttentionTenantCount,
|
|
int $totalActiveOperationsCount,
|
|
int $totalAlertFailuresCount,
|
|
bool $canViewAlerts,
|
|
CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$checkedDomains = ['tenant_access', 'governance', 'findings', 'compare', 'operations'];
|
|
|
|
if ($canViewAlerts) {
|
|
$checkedDomains[] = 'alerts';
|
|
}
|
|
|
|
if ($accessibleTenantCount === 0) {
|
|
return [
|
|
'is_calm' => false,
|
|
'checked_domains' => $checkedDomains,
|
|
'title' => 'No accessible tenants in this workspace',
|
|
'body' => 'This workspace is not calm or healthy yet because your current scope has no visible tenants. Switch workspace or review workspace-wide operations while access is being restored.',
|
|
'next_action' => $this->switchWorkspaceTarget(),
|
|
];
|
|
}
|
|
|
|
$hasActivityAttention = $totalActiveOperationsCount > 0 || ($canViewAlerts && $totalAlertFailuresCount > 0);
|
|
$isCalm = $governanceAttentionTenantCount === 0 && ! $hasActivityAttention;
|
|
|
|
if ($isCalm) {
|
|
return [
|
|
'is_calm' => true,
|
|
'checked_domains' => $checkedDomains,
|
|
'title' => 'Nothing urgent in your visible workspace slice',
|
|
'body' => 'Visible governance, findings, compare posture, and activity currently look calm. Choose a tenant deliberately if you want to inspect one in more detail.',
|
|
'next_action' => $this->chooseTenantTarget(),
|
|
];
|
|
}
|
|
|
|
if ($attentionItems === []) {
|
|
return [
|
|
'is_calm' => false,
|
|
'checked_domains' => $checkedDomains,
|
|
'title' => 'Workspace activity still needs review',
|
|
'body' => 'This workspace is not calm in the domains it can check right now, but your current scope does not expose a more specific tenant drill-through here. Review operations first.',
|
|
'next_action' => $this->operationsIndexTarget(null, $navigationContext, 'blocked'),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'is_calm' => false,
|
|
'checked_domains' => $checkedDomains,
|
|
'title' => 'Visible tenants still need attention',
|
|
'body' => 'Governance risk or execution follow-up is still present in this workspace.',
|
|
'next_action' => $attentionItems[0]['destination'] ?? $this->chooseTenantTarget(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $accessibleTenantIds
|
|
*/
|
|
private function scopeToAuthorizedTenants(Builder $query, int $workspaceId, array $accessibleTenantIds): Builder
|
|
{
|
|
return $query
|
|
->where('workspace_id', $workspaceId)
|
|
->where(function (Builder $query) use ($accessibleTenantIds): void {
|
|
$query->whereNull('tenant_id');
|
|
|
|
if ($accessibleTenantIds !== []) {
|
|
$query->orWhereIn('tenant_id', $accessibleTenantIds);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $accessibleTenantIds
|
|
*/
|
|
private function scopeToVisibleTenants(Builder $query, int $workspaceId, array $accessibleTenantIds): Builder
|
|
{
|
|
return $query
|
|
->where('workspace_id', $workspaceId)
|
|
->whereIn('tenant_id', $accessibleTenantIds === [] ? [0] : $accessibleTenantIds);
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function quickActions(
|
|
Workspace $workspace,
|
|
int $accessibleTenantCount,
|
|
bool $canViewAlerts,
|
|
User $user,
|
|
CanonicalNavigationContext $navigationContext,
|
|
): array {
|
|
$actions = [
|
|
[
|
|
'key' => 'choose_tenant',
|
|
'label' => 'Choose tenant',
|
|
'description' => 'Deliberately enter tenant context from this workspace.',
|
|
'url' => ChooseTenant::getUrl(panel: 'admin'),
|
|
'icon' => 'heroicon-o-building-office-2',
|
|
'color' => 'primary',
|
|
'visible' => $accessibleTenantCount > 0,
|
|
],
|
|
[
|
|
'key' => 'operations',
|
|
'label' => 'Open operations',
|
|
'description' => 'Review current and recent workspace-wide operations.',
|
|
'url' => OperationRunLinks::index(context: $navigationContext, allTenants: true),
|
|
'icon' => 'heroicon-o-queue-list',
|
|
'color' => 'gray',
|
|
'visible' => true,
|
|
],
|
|
[
|
|
'key' => 'alerts',
|
|
'label' => 'Open alerts',
|
|
'description' => 'Inspect alert overview, rules, and deliveries.',
|
|
'url' => $this->alertsOverviewUrl($navigationContext),
|
|
'icon' => 'heroicon-o-bell-alert',
|
|
'color' => 'gray',
|
|
'visible' => $canViewAlerts,
|
|
],
|
|
[
|
|
'key' => 'switch_workspace',
|
|
'label' => 'Switch workspace',
|
|
'description' => 'Change the active workspace context.',
|
|
'url' => $this->switchWorkspaceUrl(),
|
|
'icon' => 'heroicon-o-arrows-right-left',
|
|
'color' => 'gray',
|
|
'visible' => true,
|
|
],
|
|
[
|
|
'key' => 'manage_workspaces',
|
|
'label' => 'Manage workspaces',
|
|
'description' => 'Open workspace management and memberships.',
|
|
'url' => route('filament.admin.resources.workspaces.index'),
|
|
'icon' => 'heroicon-o-squares-2x2',
|
|
'color' => 'gray',
|
|
'visible' => $this->canManageWorkspaces($workspace, $user),
|
|
],
|
|
];
|
|
|
|
return collect($actions)
|
|
->filter(fn (array $action): bool => (bool) $action['visible'])
|
|
->map(function (array $action): array {
|
|
unset($action['visible']);
|
|
|
|
return $action;
|
|
})
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function canManageWorkspaces(Workspace $workspace, User $user): bool
|
|
{
|
|
if ($this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::WORKSPACE_MANAGE)) {
|
|
return true;
|
|
}
|
|
|
|
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
|
|
|
return $user->workspaceMemberships()
|
|
->whereIn('role', $roles)
|
|
->exists();
|
|
}
|
|
|
|
private function tenantRouteKey(Tenant $tenant): string
|
|
{
|
|
return filled($tenant->external_id)
|
|
? (string) $tenant->external_id
|
|
: (string) $tenant->getKey();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function chooseTenantTarget(string $label = 'Choose tenant'): array
|
|
{
|
|
return $this->destination(
|
|
kind: 'choose_tenant',
|
|
url: ChooseTenant::getUrl(panel: 'admin'),
|
|
label: $label,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function switchWorkspaceTarget(string $label = 'Switch workspace'): array
|
|
{
|
|
return $this->destination(
|
|
kind: 'switch_workspace',
|
|
url: $this->switchWorkspaceUrl(),
|
|
label: $label,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function tenantDashboardTarget(Tenant $tenant, User $user, string $label = 'Open tenant dashboard'): array
|
|
{
|
|
if (! $this->canTenantView($user, $tenant)) {
|
|
return $this->disabledDestination(
|
|
kind: 'tenant_dashboard',
|
|
label: $label,
|
|
tenant: $tenant,
|
|
);
|
|
}
|
|
|
|
return $this->destination(
|
|
kind: 'tenant_dashboard',
|
|
url: TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
|
|
label: $label,
|
|
tenant: $tenant,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function findingsTarget(Tenant $tenant, User $user, array $filters, string $label = 'Open findings'): array
|
|
{
|
|
if ($this->canOpenFindings($user, $tenant)) {
|
|
return $this->destination(
|
|
kind: 'tenant_findings',
|
|
url: FindingResource::getUrl('index', $filters, panel: 'tenant', tenant: $tenant),
|
|
label: $label,
|
|
tenant: $tenant,
|
|
filters: $filters,
|
|
);
|
|
}
|
|
|
|
if ($this->canTenantView($user, $tenant)) {
|
|
return $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard');
|
|
}
|
|
|
|
return $this->disabledDestination(
|
|
kind: 'tenant_findings',
|
|
label: $label,
|
|
tenant: $tenant,
|
|
filters: $filters,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function baselineCompareTarget(Tenant $tenant, User $user, string $label = 'Open Baseline Compare'): array
|
|
{
|
|
if (! $this->canTenantView($user, $tenant)) {
|
|
return $this->disabledDestination(
|
|
kind: 'baseline_compare_landing',
|
|
label: $label,
|
|
tenant: $tenant,
|
|
);
|
|
}
|
|
|
|
return $this->destination(
|
|
kind: 'baseline_compare_landing',
|
|
url: BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant),
|
|
label: $label,
|
|
tenant: $tenant,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function operationsIndexTarget(
|
|
?Tenant $tenant,
|
|
CanonicalNavigationContext $navigationContext,
|
|
?string $activeTab = null,
|
|
string $label = 'Open operations',
|
|
): array {
|
|
return $this->destination(
|
|
kind: 'operations_index',
|
|
url: OperationRunLinks::index($tenant, $navigationContext, $activeTab, $tenant === null),
|
|
label: $label,
|
|
tenant: $tenant,
|
|
filters: array_filter([
|
|
'tenant_id' => $tenant?->getKey(),
|
|
'activeTab' => $activeTab,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function operationDetailTarget(OperationRun $run, CanonicalNavigationContext $navigationContext): array
|
|
{
|
|
$tenant = $run->tenant instanceof Tenant ? $run->tenant : null;
|
|
|
|
return $this->destination(
|
|
kind: 'operation_detail',
|
|
url: OperationRunLinks::tenantlessView($run, $navigationContext),
|
|
label: 'Open operation',
|
|
tenant: $tenant,
|
|
filters: ['run' => (int) $run->getKey()],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function alertsOverviewTarget(CanonicalNavigationContext $navigationContext, bool $enabled, string $label = 'Open alerts'): array
|
|
{
|
|
if (! $enabled) {
|
|
return $this->disabledDestination(
|
|
kind: 'alerts_overview',
|
|
label: $label,
|
|
);
|
|
}
|
|
|
|
return $this->destination(
|
|
kind: 'alerts_overview',
|
|
url: $this->alertsOverviewUrl($navigationContext),
|
|
label: $label,
|
|
);
|
|
}
|
|
|
|
private function alertsOverviewUrl(CanonicalNavigationContext $navigationContext): string
|
|
{
|
|
return $this->appendQuery(route('filament.admin.alerts'), $navigationContext->toQuery());
|
|
}
|
|
|
|
private function canTenantView(User $user, Tenant $tenant): bool
|
|
{
|
|
return $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
|
}
|
|
|
|
private function canOpenFindings(User $user, Tenant $tenant): bool
|
|
{
|
|
return $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function destination(
|
|
string $kind,
|
|
string $url,
|
|
string $label,
|
|
?Tenant $tenant = null,
|
|
array $filters = [],
|
|
): array {
|
|
return [
|
|
'kind' => $kind,
|
|
'url' => $url,
|
|
'tenant_route_key' => $tenant instanceof Tenant ? $this->tenantRouteKey($tenant) : null,
|
|
'label' => $label,
|
|
'disabled' => false,
|
|
'helper_text' => null,
|
|
'filters' => $filters !== [] ? $filters : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function disabledDestination(
|
|
string $kind,
|
|
string $label,
|
|
?Tenant $tenant = null,
|
|
array $filters = [],
|
|
): array {
|
|
return [
|
|
'kind' => $kind,
|
|
'url' => null,
|
|
'tenant_route_key' => $tenant instanceof Tenant ? $this->tenantRouteKey($tenant) : null,
|
|
'label' => $label,
|
|
'disabled' => true,
|
|
'helper_text' => UiTooltips::INSUFFICIENT_PERMISSION,
|
|
'filters' => $filters !== [] ? $filters : null,
|
|
];
|
|
}
|
|
|
|
private function workspaceOverviewNavigationContext(): CanonicalNavigationContext
|
|
{
|
|
return new CanonicalNavigationContext(
|
|
sourceSurface: 'workspace.overview',
|
|
canonicalRouteName: 'admin.home',
|
|
tenantId: null,
|
|
backLinkLabel: 'Back to overview',
|
|
backLinkUrl: route('admin.home'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
*/
|
|
private function appendQuery(string $url, array $query): string
|
|
{
|
|
if ($query === []) {
|
|
return $url;
|
|
}
|
|
|
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
|
}
|
|
|
|
private function switchWorkspaceUrl(): string
|
|
{
|
|
return route('filament.admin.pages.choose-workspace').'?choose=1';
|
|
}
|
|
}
|