mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
381 lines
15 KiB
PHP
381 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\ResourceLink;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Support\ResourceStartActivity;
|
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Collection;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
|
|
/**
|
|
* Builds the environment canvas graph: server zones, resource cards, manual links and saved positions.
|
|
*
|
|
* Cards hold summary fields only. Never add environment variables, passwords, keys or connection URLs.
|
|
*/
|
|
class EnvironmentCanvas
|
|
{
|
|
public const MAX_COORDINATE = 100000;
|
|
|
|
/**
|
|
* Environment relations that hold standalone databases, keyed by relation name.
|
|
*/
|
|
public const DATABASE_RELATIONS = [
|
|
'postgresqls',
|
|
'redis',
|
|
'mongodbs',
|
|
'mysqls',
|
|
'mariadbs',
|
|
'keydbs',
|
|
'dragonflies',
|
|
'clickhouses',
|
|
'sqlites',
|
|
];
|
|
|
|
private const DATABASE_ENGINE_NAMES = [
|
|
'postgresql' => '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<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array{id: string, source: string, target: string, label: ?string}>, positions: array<string, array{x: int, y: int}>}
|
|
*/
|
|
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<string, Model> $resources keyed by uuid
|
|
* @param Collection<int, ResourceLink> $links
|
|
* @return array{zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array{id: string, source: string, target: string, label: ?string}>}
|
|
*/
|
|
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<string, Model>
|
|
*/
|
|
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<string, array{type: string, id: int}>
|
|
*/
|
|
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<string, mixed> $positions
|
|
* @param array<int, string> $nodeUuids
|
|
* @return array<string, array{x: int, y: int}>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, mixed>
|
|
*/
|
|
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<string, Service> $services
|
|
* @return array<int, string>
|
|
*/
|
|
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<int, Activity> $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<int, string>
|
|
*/
|
|
private function splitUrls(?string $fqdn): array
|
|
{
|
|
return str((string) $fqdn)->explode(',')
|
|
->map(fn (string $url) => trim($url))
|
|
->filter()
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, ?string> $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;
|
|
}
|
|
}
|