TenantAtlas/app/Support/Workspaces/WorkspaceOverviewBuilder.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';
}
}