477 lines
18 KiB
PHP
477 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 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,
|
|
* 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,
|
|
* 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),
|
|
'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';
|
|
}
|
|
}
|