'PostgreSQL', 'redis' => 'Redis', 'mongodb' => 'MongoDB', 'mysql' => 'MySQL', 'mariadb' => 'MariaDB', 'keydb' => 'KeyDB', 'dragonfly' => 'Dragonfly', 'clickhouse' => 'ClickHouse', 'sqlite' => 'SQLite', ]; private const BUILD_PACK_NAMES = [ 'nixpacks' => 'Nixpacks', 'railpack' => 'Railpack', 'static' => 'Static', 'dockerfile' => 'Dockerfile', 'dockercompose' => 'Docker Compose', 'dockerimage' => 'Docker Image', ]; /** * The canvas payload of an environment. * * @return array{project: array{uuid: string, name: string, link: string}, environment: array{uuid: string, name: string, link: string}, can_edit: bool, zones: array>, nodes: array>, links: array, positions: array} */ public function payload(Environment $environment, bool $canReadSensitive, bool $canEdit): array { $environment->loadMissing('project'); $resources = $this->loadResources($environment); $graph = $this->graph($resources, $environment->resourceLinks()->orderBy('id')->get(), $canReadSensitive); return [ 'project' => [ 'uuid' => $environment->project->uuid, 'name' => $environment->project->name, 'link' => route('project.show', ['project_uuid' => $environment->project->uuid]), ], 'environment' => [ 'uuid' => $environment->uuid, 'name' => $environment->name, 'link' => route('project.resource.index', ['project_uuid' => $environment->project->uuid, 'environment_uuid' => $environment->uuid]), ], 'can_edit' => $canEdit, ...$graph, 'positions' => $this->filterPositions($environment->canvas_positions ?? [], $resources->keys()->all()), ]; } /** * Zones (servers), cards and links of the given resources. Links with an end outside the resources are left out. * * @param Collection $resources keyed by uuid * @param Collection $links * @return array{zones: array>, nodes: array>, links: array} */ public function graph(Collection $resources, Collection $links, bool $canReadSensitive): array { $startingServiceUuids = $this->startingServiceUuids($resources->filter(fn (Model $resource) => $resource instanceof Service)); $servers = collect(); $nodes = $resources->map(function (Model $resource) use (&$servers, $startingServiceUuids) { $node = match (true) { $resource instanceof Application => $this->applicationNode($resource), $resource instanceof Service => $this->serviceNode($resource, in_array($resource->uuid, $startingServiceUuids, true)), default => $this->databaseNode($resource), }; foreach ($node['servers'] as $server) { $servers->put($server->id, $server); } unset($node['servers']); return $node; })->values(); $uuidByMorphKey = $resources->mapWithKeys(fn (Model $resource) => [$this->morphKey($resource->getMorphClass(), $resource->getKey()) => $resource->uuid]); $links = $links ->map(fn (ResourceLink $link) => [ 'link' => $link, 'source' => $uuidByMorphKey->get($this->morphKey($link->source_type, $link->source_id)), 'target' => $uuidByMorphKey->get($this->morphKey($link->target_type, $link->target_id)), ]) ->filter(fn (array $link) => $link['source'] !== null && $link['target'] !== null) ->map(fn (array $link) => $this->linkPayload($link['link'], $link['source'], $link['target'])) ->values(); return [ 'zones' => $servers->values()->map(fn (Server $server) => $this->zone($server, $canReadSensitive))->all(), 'nodes' => $nodes->all(), 'links' => $links->all(), ]; } /** * @return array{id: string, name: string, ip: ?string, is_reachable: bool, is_usable: bool, link: string} */ public function zone(Server $server, bool $canReadSensitive): array { return [ 'id' => $server->uuid, 'name' => $server->name, 'ip' => $canReadSensitive ? $server->ip : null, 'is_reachable' => (bool) $server->settings?->is_reachable, 'is_usable' => (bool) $server->settings?->is_usable, 'link' => route('server.show', ['server_uuid' => $server->uuid]), ]; } /** * Top-level resources of the environment (applications, services, standalone databases), keyed by uuid, * with the relations the canvas reads. * * @return Collection */ public function loadResources(Environment $environment): Collection { $project = $environment->project; $environment->setRelation('project', $project); $applications = $environment->applications() ->with(['destination.server.settings', 'additional_servers.settings', 'persistentStorages']) ->get(); $services = $environment->services() ->with([ 'server.settings', 'destination.server.settings', 'applications.persistentStorages', 'databases.persistentStorages', ]) ->get(); $databases = collect(self::DATABASE_RELATIONS) ->flatMap(fn (string $relation) => $environment->{$relation}() ->with(['destination.server.settings', 'persistentStorages']) ->get()); return collect() ->concat($applications) ->concat($services) ->concat($databases) ->each(fn (Model $resource) => $resource->setRelation('environment', $environment)) ->keyBy('uuid'); } /** * Morph type and id of every top-level resource of the environment, keyed by uuid. Reads ids only. * * @return Collection */ public function resourceIndex(Environment $environment): Collection { return collect(['applications', 'services', ...self::DATABASE_RELATIONS]) ->flatMap(function (string $relation) use ($environment) { $query = $environment->{$relation}(); $type = $query->getRelated()->getMorphClass(); return $query->toBase()->get(['id', 'uuid']) ->map(fn (object $row) => ['uuid' => $row->uuid, 'type' => $type, 'id' => (int) $row->id]); }) ->keyBy('uuid') ->map(fn (array $row) => ['type' => $row['type'], 'id' => $row['id']]); } /** * Keep only positions of the given node uuids, as integer coordinates. * * @param array $positions * @param array $nodeUuids * @return array */ public function filterPositions(array $positions, array $nodeUuids): array { return collect($positions) ->only($nodeUuids) ->filter(fn ($position) => is_array($position) && is_numeric($position['x'] ?? null) && is_numeric($position['y'] ?? null)) ->map(fn (array $position) => ['x' => (int) $position['x'], 'y' => (int) $position['y']]) ->all(); } /** * @return array{id: string, source: string, target: string, label: ?string} */ public function linkPayload(ResourceLink $link, string $sourceUuid, string $targetUuid): array { return [ 'id' => $link->uuid, 'source' => $sourceUuid, 'target' => $targetUuid, 'label' => $link->label, ]; } /** * @return array */ private function applicationNode(Application $application): array { $server = $application->destination?->server; $extraServers = $application->additional_servers->filter()->values(); $buildPack = $application->build_pack; $detail = $buildPack === 'dockerimage' ? collect([$application->docker_registry_image_name, $application->docker_registry_image_tag])->filter()->implode(':') : $application->git_branch; return [ 'id' => $application->uuid, 'type' => 'application', 'name' => $application->name, 'status' => (string) $application->status, 'zone' => $server?->uuid, 'extra_zones' => $extraServers->pluck('uuid')->reject(fn ($uuid) => $uuid === $server?->uuid)->unique()->values()->all(), 'urls' => $this->splitUrls($application->fqdn), 'kind' => $buildPack, 'subtitle' => $this->subtitle([self::BUILD_PACK_NAMES[$buildPack] ?? str($buildPack)->headline()->value(), $detail]), 'children' => [], 'volumes' => $application->persistentStorages->pluck('name')->filter()->values()->all(), 'link' => $application->link(), 'logo' => resource_logo($application), 'servers' => collect([$server])->concat($extraServers)->filter()->all(), ]; } /** * @return array */ private function serviceNode(Service $service, bool $isStarting): array { $server = $service->server ?? $service->destination?->server; $children = $service->applications ->map(fn ($child) => ['name' => $child->name, 'type' => 'application', 'status' => (string) $child->status]) ->concat($service->databases->map(fn ($child) => ['name' => $child->name, 'type' => 'database', 'status' => (string) $child->status])) ->values(); $containerCount = $children->count(); return [ 'id' => $service->uuid, 'type' => 'service', 'name' => $service->name, 'status' => $isStarting ? 'starting:unhealthy' : (string) $service->containerStatus(), 'zone' => $server?->uuid, 'extra_zones' => [], 'urls' => $service->applications ->flatMap(fn ($child) => $this->splitUrls($child->fqdn)) ->unique() ->values() ->all(), 'kind' => null, 'subtitle' => $this->subtitle(['Service', $containerCount.' '.str('container')->plural($containerCount)]), 'children' => $children->all(), 'volumes' => $service->applications->concat($service->databases) ->flatMap(fn ($child) => $child->persistentStorages->pluck('name')) ->filter() ->unique() ->values() ->all(), 'link' => $service->link(), 'logo' => resource_logo($service), 'servers' => collect([$server])->filter()->all(), ]; } /** * @return array */ private function databaseNode(Model $database): array { $server = $database->destination?->server; $engine = str($database->type())->after('standalone-')->value(); return [ 'id' => $database->uuid, 'type' => 'database', 'name' => $database->name, 'status' => (string) $database->status, 'zone' => $server?->uuid, 'extra_zones' => [], 'urls' => [], 'kind' => $engine, 'subtitle' => $this->subtitle([self::DATABASE_ENGINE_NAMES[$engine] ?? str($engine)->headline()->value(), $database->image]), 'children' => [], 'volumes' => $database->persistentStorages->pluck('name')->filter()->values()->all(), 'link' => $database->link(), 'logo' => resource_logo($database), 'servers' => collect([$server])->filter()->all(), ]; } /** * Uuids of services with a queued or running start, read with one query instead of one per service. * * @param Collection $services * @return array */ private function startingServiceUuids(Collection $services): array { if ($services->isEmpty()) { return []; } $staleAfterSeconds = max(ResourceStartActivity::QUEUED_STALE_AFTER_SECONDS, ResourceStartActivity::IN_PROGRESS_STALE_AFTER_SECONDS); /** @var EloquentCollection $activities */ $activities = Activity::query() ->whereIn('properties->type_uuid', $services->pluck('uuid')->all()) ->where('updated_at', '>=', now()->subSeconds($staleAfterSeconds)) ->latest() ->orderByDesc('id') ->get(); return $activities ->groupBy(fn (Activity $activity) => (string) data_get($activity, 'properties.type_uuid')) ->filter(fn (EloquentCollection $group) => ResourceStartActivity::isRunning($group->first())) ->keys() ->all(); } /** * @return array */ private function splitUrls(?string $fqdn): array { return str((string) $fqdn)->explode(',') ->map(fn (string $url) => trim($url)) ->filter() ->unique() ->values() ->all(); } /** * @param array $parts */ private function subtitle(array $parts): string { return collect($parts)->map(fn ($part) => trim((string) $part))->filter()->implode(' ยท '); } private function morphKey(string $type, int|string $id): string { return $type.'|'.$id; } }