TenantAtlas/app/Support/Workspaces/WorkspaceOverviewBuilder.php
2026-03-22 11:24:10 +01:00

481 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Resources\AlertDeliveryResource;
use App\Models\AlertDelivery;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
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\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
final class WorkspaceOverviewBuilder
{
public function __construct(
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
) {}
/**
* @return array{
* workspace: array{id: int, name: string, slug: ?string},
* accessible_tenant_count: int,
* summary_metrics: list<array{
* key: string,
* label: string,
* value: int,
* description: string,
* destination_url: ?string,
* color: string
* }>,
* attention_items: list<array{
* title: string,
* body: string,
* url: string,
* badge: string,
* badge_color: string
* }>,
* attention_empty_state: array{
* title: string,
* body: string,
* action_label: string,
* action_url: string
* },
* recent_operations: list<array{
* id: int,
* title: string,
* tenant_label: ?string,
* status_label: string,
* status_color: string,
* outcome_label: string,
* outcome_color: string,
* guidance: ?string,
* started_at: string,
* url: string
* }>,
* recent_operations_empty_state: array{
* title: string,
* body: string,
* action_label: string,
* action_url: string
* },
* quick_actions: list<array{
* key: string,
* label: string,
* description: string,
* url: string,
* icon: string,
* color: string
* }>,
* zero_tenant_state: ?array{
* title: string,
* body: string,
* action_label: string,
* action_url: string
* }
* }
*/
public function build(Workspace $workspace, User $user): array
{
$accessibleTenants = $this->accessibleTenants($workspace, $user);
$accessibleTenantIds = $accessibleTenants
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->all();
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
$recentOperations = $this->recentOperations((int) $workspace->getKey(), $accessibleTenantIds);
$attentionItems = $this->attentionItems((int) $workspace->getKey(), $accessibleTenantIds, $canViewAlerts);
$quickActions = $this->quickActions($workspace, $accessibleTenants->count(), $canViewAlerts, $user);
$zeroTenantState = null;
if ($accessibleTenants->isEmpty()) {
$fallbackAction = collect($quickActions)
->first(fn (array $action): bool => in_array($action['key'], ['manage_workspaces', 'switch_workspace'], true));
$zeroTenantState = [
'title' => 'No accessible tenants in this workspace',
'body' => 'You can still review workspace-wide operations or switch to another workspace while tenant access is being set up.',
'action_label' => is_array($fallbackAction) ? $fallbackAction['label'] : 'Switch workspace',
'action_url' => is_array($fallbackAction) ? $fallbackAction['url'] : $this->switchWorkspaceUrl(),
];
}
return [
'workspace' => [
'id' => (int) $workspace->getKey(),
'name' => (string) $workspace->name,
'slug' => filled($workspace->slug) ? (string) $workspace->slug : null,
],
'accessible_tenant_count' => $accessibleTenants->count(),
'summary_metrics' => $this->summaryMetrics(
workspaceId: (int) $workspace->getKey(),
accessibleTenantCount: $accessibleTenants->count(),
accessibleTenantIds: $accessibleTenantIds,
canViewAlerts: $canViewAlerts,
needsAttentionCount: count($attentionItems),
),
'attention_items' => $attentionItems,
'attention_empty_state' => [
'title' => 'Nothing urgent in your current scope',
'body' => 'Recent operations and alert deliveries look healthy right now.',
'action_label' => $canViewAlerts ? 'Open alerts' : 'Open operations',
'action_url' => $canViewAlerts ? '/admin/alerts' : route('admin.operations.index'),
],
'recent_operations' => $recentOperations,
'recent_operations_empty_state' => [
'title' => 'No recent operations yet',
'body' => 'Workspace-wide activity will show up here once syncs, evaluations, or restores start running.',
'action_label' => 'Open operations',
'action_url' => route('admin.operations.index'),
],
'quick_actions' => $quickActions,
'zero_tenant_state' => $zeroTenantState,
];
}
/**
* @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 array<int, int> $accessibleTenantIds
* @return list<array{
* key: string,
* label: string,
* value: int,
* description: string,
* destination_url: ?string,
* color: string
* }>
*/
private function summaryMetrics(
int $workspaceId,
int $accessibleTenantCount,
array $accessibleTenantIds,
bool $canViewAlerts,
int $needsAttentionCount,
): array {
$activeOperationsCount = (int) $this->scopeToAuthorizedTenants(
OperationRun::query(),
$workspaceId,
$accessibleTenantIds,
)
->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])
->count();
$metrics = [
[
'key' => 'accessible_tenants',
'label' => 'Accessible tenants',
'value' => $accessibleTenantCount,
'description' => $accessibleTenantCount > 0
? 'Tenant drill-down stays explicit from this workspace home.'
: 'No tenant memberships are available in this workspace yet.',
'destination_url' => $accessibleTenantCount > 0 ? ChooseTenant::getUrl(panel: 'admin') : null,
'color' => 'primary',
],
[
'key' => 'active_operations',
'label' => 'Active operations',
'value' => $activeOperationsCount,
'description' => 'Workspace-wide runs that are still queued or in progress.',
'destination_url' => route('admin.operations.index'),
'color' => $activeOperationsCount > 0 ? 'warning' : 'gray',
],
];
if ($canViewAlerts) {
$failedAlertDeliveriesCount = (int) $this->scopeToAuthorizedTenants(
AlertDelivery::query(),
$workspaceId,
$accessibleTenantIds,
)
->where('created_at', '>=', now()->subDays(7))
->where('status', AlertDelivery::STATUS_FAILED)
->count();
$metrics[] = [
'key' => 'alerts',
'label' => 'Alert failures (7d)',
'value' => $failedAlertDeliveriesCount,
'description' => 'Failed alert deliveries in the last 7 days.',
'destination_url' => AlertDeliveryResource::getUrl(panel: 'admin'),
'color' => $failedAlertDeliveriesCount > 0 ? 'danger' : 'gray',
];
}
$metrics[] = [
'key' => 'needs_attention',
'label' => 'Needs attention',
'value' => $needsAttentionCount,
'description' => 'Urgent workspace-safe items surfaced below.',
'destination_url' => $needsAttentionCount > 0 ? route('admin.operations.index') : null,
'color' => $needsAttentionCount > 0 ? 'warning' : 'gray',
];
return $metrics;
}
/**
* @param array<int, int> $accessibleTenantIds
* @return list<array{
* title: string,
* body: string,
* url: string,
* badge: string,
* badge_color: string
* }>
*/
private function attentionItems(int $workspaceId, array $accessibleTenantIds, bool $canViewAlerts): array
{
$items = [];
$latestFailedRun = $this->scopeToAuthorizedTenants(
OperationRun::query()->with('tenant'),
$workspaceId,
$accessibleTenantIds,
)
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Failed->value,
OperationRunOutcome::PartiallySucceeded->value,
])
->latest('created_at')
->first();
if ($latestFailedRun instanceof OperationRun) {
$items[] = [
'title' => OperationCatalog::label((string) $latestFailedRun->type).' needs review',
'body' => 'Latest outcome: '.BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $latestFailedRun->outcome)->label.'.',
'url' => route('admin.operations.view', ['run' => (int) $latestFailedRun->getKey()]),
'badge' => 'Operations',
'badge_color' => $latestFailedRun->outcome === OperationRunOutcome::Failed->value ? 'danger' : 'warning',
];
}
$activeRunsCount = (int) $this->scopeToAuthorizedTenants(
OperationRun::query(),
$workspaceId,
$accessibleTenantIds,
)
->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])
->count();
if ($activeRunsCount > 0) {
$items[] = [
'title' => 'Operations are still running',
'body' => $activeRunsCount.' workspace run(s) are active right now.',
'url' => route('admin.operations.index'),
'badge' => 'Operations',
'badge_color' => 'warning',
];
}
if ($canViewAlerts) {
$failedAlertDeliveriesCount = (int) $this->scopeToAuthorizedTenants(
AlertDelivery::query(),
$workspaceId,
$accessibleTenantIds,
)
->where('created_at', '>=', now()->subDays(7))
->where('status', AlertDelivery::STATUS_FAILED)
->count();
if ($failedAlertDeliveriesCount > 0) {
$items[] = [
'title' => 'Alert deliveries failed',
'body' => $failedAlertDeliveriesCount.' alert delivery attempt(s) failed in the last 7 days.',
'url' => AlertDeliveryResource::getUrl(panel: 'admin'),
'badge' => 'Alerts',
'badge_color' => 'danger',
];
}
}
return array_slice($items, 0, 5);
}
/**
* @param array<int, int> $accessibleTenantIds
* @return list<array{
* id: int,
* title: string,
* tenant_label: ?string,
* status_label: string,
* status_color: string,
* outcome_label: string,
* outcome_color: string,
* guidance: ?string,
* started_at: string,
* url: string
* }>
*/
private function recentOperations(int $workspaceId, array $accessibleTenantIds): 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 ($statusSpec, $statusColorSpec, $outcomeSpec, $outcomeColorSpec): array {
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',
'url' => route('admin.operations.view', ['run' => (int) $run->getKey()]),
];
})
->all();
}
/**
* @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);
}
});
}
/**
* @return list<array{
* key: string,
* label: string,
* description: string,
* url: string,
* icon: string,
* color: string
* }>
*/
private function quickActions(Workspace $workspace, int $accessibleTenantCount, bool $canViewAlerts, User $user): 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 runs.',
'url' => route('admin.operations.index'),
'icon' => 'heroicon-o-queue-list',
'color' => 'gray',
'visible' => true,
],
[
'key' => 'alerts',
'label' => 'Open alerts',
'description' => 'Inspect alert overview, rules, and deliveries.',
'url' => '/admin/alerts',
'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 switchWorkspaceUrl(): string
{
return route('filament.admin.pages.choose-workspace').'?choose=1';
}
}