From 33ee09fa87fddea241b1c4be3d9a99def78adc73 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:28:10 +0200 Subject: [PATCH] feat(canvas): add interactive resource views and team avatars (#12160) --- .ai/lessons.md | 2 +- .../Api/Concerns/HandlesTagsApi.php | 2 +- .../Api/EnvironmentCanvasController.php | 264 +++++ .../Api/ProjectCanvasController.php | 54 + .../Api/ServerCanvasController.php | 80 ++ .../Api/SharedVariableCanvasController.php | 199 ++++ .../Controllers/Api/TagCanvasController.php | 107 ++ .../Controllers/ProfileAvatarController.php | 14 +- app/Http/Controllers/TeamAvatarController.php | 22 + .../Middleware/UseSessionTeamApiToken.php | 51 + app/Livewire/Concerns/CreatesEnvironments.php | 50 + app/Livewire/Concerns/CreatesTags.php | 39 + app/Livewire/Concerns/RemembersCanvasView.php | 72 ++ app/Livewire/DeployCanvas.php | 83 ++ app/Livewire/GlobalSearch.php | 20 + app/Livewire/Project/EnvironmentCanvas.php | 41 + app/Livewire/Project/Index.php | 9 + app/Livewire/Project/New/Select.php | 38 +- app/Livewire/Project/ProjectCanvas.php | 38 + app/Livewire/Project/ProjectsCanvas.php | 37 + app/Livewire/Project/Resource/Index.php | 11 + .../Shared/EnvironmentVariable/Show.php | 10 + app/Livewire/Project/Shared/Tags.php | 5 +- app/Livewire/Project/Show.php | 46 +- app/Livewire/Server/Canvas.php | 46 + .../DockerRegistries/ServerRegistries.php | 9 +- app/Livewire/Server/Index.php | 11 +- app/Livewire/Server/Resources.php | 5 + app/Livewire/SharedVariables/Canvas.php | 147 +++ .../SharedVariables/Environment/Show.php | 5 + app/Livewire/SharedVariables/Project/Show.php | 5 + app/Livewire/SharedVariables/Server/Show.php | 5 + app/Livewire/SharedVariables/Team/Index.php | 5 + app/Livewire/SwitchTeam.php | 2 + app/Livewire/Tags/Canvas.php | 39 + app/Livewire/Tags/Show.php | 27 + app/Livewire/Team/Index.php | 39 + app/Models/Environment.php | 18 + app/Models/ResourceLink.php | 37 + app/Models/Service.php | 9 + app/Models/Tag.php | 8 - app/Models/Team.php | 5 + app/Services/EnvironmentCanvas.php | 380 +++++++ app/Services/ProjectCanvas.php | 106 ++ app/Services/ServerCanvas.php | 142 +++ app/Services/SharedVariableCanvas.php | 297 ++++++ app/Services/TagCanvas.php | 120 +++ app/Services/TeamAvatarStorageService.php | 76 ++ bootstrap/helpers/services.php | 96 ++ bootstrap/helpers/shared.php | 23 + ...canvas_positions_to_environments_table.php | 22 + ..._12_192445_create_resource_links_table.php | 29 + ...2049_add_avatar_columns_to_teams_table.php | 31 + package-lock.json | 229 ++++- package.json | 2 + public/svgs/resources/docker-compose.svg | 11 +- public/svgs/resources/docker-image.svg | 11 +- public/svgs/resources/dockerfile.svg | 10 +- resources/css/app.css | 471 ++++++++- resources/js/app.js | 43 + resources/js/canvas-core.js | 246 +++++ .../js/canvas/deploy/DeployCanvas.svelte | 165 +++ resources/js/canvas/deploy/Dropdown.svelte | 137 +++ resources/js/canvas/deploy/Edges.svelte | 48 + resources/js/canvas/deploy/Field.svelte | 105 ++ resources/js/canvas/deploy/Icon.svelte | 25 + resources/js/canvas/deploy/Node.svelte | 104 ++ resources/js/canvas/deploy/PaletteItem.svelte | 21 + resources/js/canvas/deploy/RunPanel.svelte | 48 + resources/js/canvas/deploy/index.js | 18 + resources/js/canvas/deploy/state.svelte.js | 661 ++++++++++++ resources/js/canvas/deploy/styles.js | 319 ++++++ .../js/canvas/environment/AddSection.svelte | 40 + resources/js/canvas/environment/Card.svelte | 116 +++ .../environment/EnvironmentCanvas.svelte | 960 ++++++++++++++++++ resources/js/canvas/environment/Icon.svelte | 10 + .../js/canvas/environment/NodeIcon.svelte | 37 + resources/js/canvas/environment/Panel.svelte | 324 ++++++ .../js/canvas/environment/StatusDot.svelte | 13 + .../js/canvas/environment/Toolbar.svelte | 51 + resources/js/canvas/environment/Zone.svelte | 32 + resources/js/canvas/environment/index.js | 11 + resources/js/deploy-canvas.js | 510 ++++++++++ resources/js/deploy-canvas.test.js | 178 ++++ resources/js/environment-canvas.js | 621 +++++++++++ resources/js/environment-canvas.test.js | 437 ++++++++ .../components/canvas-view-toggle.blade.php | 51 + .../components/dashboard/navbar.blade.php | 2 +- resources/views/components/navbar.blade.php | 2 +- resources/views/components/reicon.blade.php | 2 + .../views/components/resource-logo.blade.php | 31 + .../components/server-timing-hud.blade.php | 7 +- .../views/components/server/sidebar.blade.php | 27 +- .../shared-variables/editor.blade.php | 8 + .../shared-variables/layout.blade.php | 25 +- .../shared-variables/sidebar.blade.php | 24 + .../views/components/team/avatar.blade.php | 10 + .../views/components/theme-controls.blade.php | 138 +++ .../views/components/top-breadcrumb.blade.php | 2 +- resources/views/layouts/app.blade.php | 47 +- resources/views/layouts/base.blade.php | 16 +- .../views/livewire/deploy-canvas.blade.php | 31 + .../views/livewire/global-search.blade.php | 2 +- .../application/deployment/index.blade.php | 2 +- .../application/deployment/show.blade.php | 4 +- .../project/environment-canvas.blade.php | 37 + .../views/livewire/project/index.blade.php | 27 +- .../livewire/project/new/select.blade.php | 23 + .../partials/new-environment-form.blade.php | 11 + .../livewire/project/project-canvas.blade.php | 38 + .../project/projects-canvas.blade.php | 31 + .../livewire/project/resource/index.blade.php | 52 +- .../environment-variable/show.blade.php | 12 + .../views/livewire/project/show.blade.php | 40 +- .../views/livewire/server/canvas.blade.php | 44 + .../server-registries-placeholder.blade.php | 12 + .../views/livewire/server/index.blade.php | 25 +- .../partials/localhost-general.blade.php | 35 +- .../views/livewire/server/resources.blade.php | 11 +- .../views/livewire/server/show.blade.php | 28 +- .../views/livewire/settings-oauth.blade.php | 6 +- .../shared-variables/canvas.blade.php | 31 + .../views/livewire/switch-team.blade.php | 8 +- .../views/livewire/tags/canvas.blade.php | 29 + .../tags/partials/new-tag-form.blade.php | 7 + resources/views/livewire/tags/show.blade.php | 70 +- resources/views/livewire/team/index.blade.php | 61 ++ .../views/livewire/team/member.blade.php | 13 +- routes/web.php | 88 ++ tests/Feature/DeployCanvasPageTest.php | 172 ++++ tests/Feature/EnvironmentCanvasTest.php | 439 ++++++++ tests/Feature/GlobalSearchNavigationTest.php | 17 + tests/Feature/LocalhostServerDetailsTest.php | 38 +- tests/Feature/ProfileAvatarTest.php | 38 + tests/Feature/ProjectCanvasTest.php | 209 ++++ tests/Feature/ResourceLogoTest.php | 71 ++ tests/Feature/ServerCanvasTest.php | 325 ++++++ .../ServiceTemplatesLastUpdatedHintTest.php | 2 +- tests/Feature/SettingsSearchItemsTest.php | 3 +- tests/Feature/SharedVariableCanvasTest.php | 342 +++++++ tests/Feature/TagApiTest.php | 5 +- tests/Feature/TagCanvasTest.php | 226 +++++ tests/Feature/TagsShowPageTest.php | 2 +- tests/Feature/TeamAvatarTest.php | 168 +++ tests/v4/Browser/DeployCanvasTest.php | 161 +++ vite.config.js | 3 + 146 files changed, 12076 insertions(+), 367 deletions(-) create mode 100644 app/Http/Controllers/Api/EnvironmentCanvasController.php create mode 100644 app/Http/Controllers/Api/ProjectCanvasController.php create mode 100644 app/Http/Controllers/Api/ServerCanvasController.php create mode 100644 app/Http/Controllers/Api/SharedVariableCanvasController.php create mode 100644 app/Http/Controllers/Api/TagCanvasController.php create mode 100644 app/Http/Controllers/TeamAvatarController.php create mode 100644 app/Http/Middleware/UseSessionTeamApiToken.php create mode 100644 app/Livewire/Concerns/CreatesEnvironments.php create mode 100644 app/Livewire/Concerns/CreatesTags.php create mode 100644 app/Livewire/Concerns/RemembersCanvasView.php create mode 100644 app/Livewire/DeployCanvas.php create mode 100644 app/Livewire/Project/EnvironmentCanvas.php create mode 100644 app/Livewire/Project/ProjectCanvas.php create mode 100644 app/Livewire/Project/ProjectsCanvas.php create mode 100644 app/Livewire/Server/Canvas.php create mode 100644 app/Livewire/SharedVariables/Canvas.php create mode 100644 app/Livewire/Tags/Canvas.php create mode 100644 app/Models/ResourceLink.php create mode 100644 app/Services/EnvironmentCanvas.php create mode 100644 app/Services/ProjectCanvas.php create mode 100644 app/Services/ServerCanvas.php create mode 100644 app/Services/SharedVariableCanvas.php create mode 100644 app/Services/TagCanvas.php create mode 100644 app/Services/TeamAvatarStorageService.php create mode 100644 database/migrations/2026_10_12_192444_add_canvas_positions_to_environments_table.php create mode 100644 database/migrations/2026_10_12_192445_create_resource_links_table.php create mode 100644 database/migrations/2026_10_13_132049_add_avatar_columns_to_teams_table.php create mode 100644 resources/js/canvas-core.js create mode 100644 resources/js/canvas/deploy/DeployCanvas.svelte create mode 100644 resources/js/canvas/deploy/Dropdown.svelte create mode 100644 resources/js/canvas/deploy/Edges.svelte create mode 100644 resources/js/canvas/deploy/Field.svelte create mode 100644 resources/js/canvas/deploy/Icon.svelte create mode 100644 resources/js/canvas/deploy/Node.svelte create mode 100644 resources/js/canvas/deploy/PaletteItem.svelte create mode 100644 resources/js/canvas/deploy/RunPanel.svelte create mode 100644 resources/js/canvas/deploy/index.js create mode 100644 resources/js/canvas/deploy/state.svelte.js create mode 100644 resources/js/canvas/deploy/styles.js create mode 100644 resources/js/canvas/environment/AddSection.svelte create mode 100644 resources/js/canvas/environment/Card.svelte create mode 100644 resources/js/canvas/environment/EnvironmentCanvas.svelte create mode 100644 resources/js/canvas/environment/Icon.svelte create mode 100644 resources/js/canvas/environment/NodeIcon.svelte create mode 100644 resources/js/canvas/environment/Panel.svelte create mode 100644 resources/js/canvas/environment/StatusDot.svelte create mode 100644 resources/js/canvas/environment/Toolbar.svelte create mode 100644 resources/js/canvas/environment/Zone.svelte create mode 100644 resources/js/canvas/environment/index.js create mode 100644 resources/js/deploy-canvas.js create mode 100644 resources/js/deploy-canvas.test.js create mode 100644 resources/js/environment-canvas.js create mode 100644 resources/js/environment-canvas.test.js create mode 100644 resources/views/components/canvas-view-toggle.blade.php create mode 100644 resources/views/components/resource-logo.blade.php create mode 100644 resources/views/components/shared-variables/sidebar.blade.php create mode 100644 resources/views/components/team/avatar.blade.php create mode 100644 resources/views/livewire/deploy-canvas.blade.php create mode 100644 resources/views/livewire/project/environment-canvas.blade.php create mode 100644 resources/views/livewire/project/partials/new-environment-form.blade.php create mode 100644 resources/views/livewire/project/project-canvas.blade.php create mode 100644 resources/views/livewire/project/projects-canvas.blade.php create mode 100644 resources/views/livewire/server/canvas.blade.php create mode 100644 resources/views/livewire/server/docker-registries/server-registries-placeholder.blade.php create mode 100644 resources/views/livewire/shared-variables/canvas.blade.php create mode 100644 resources/views/livewire/tags/canvas.blade.php create mode 100644 resources/views/livewire/tags/partials/new-tag-form.blade.php create mode 100644 tests/Feature/DeployCanvasPageTest.php create mode 100644 tests/Feature/EnvironmentCanvasTest.php create mode 100644 tests/Feature/ProjectCanvasTest.php create mode 100644 tests/Feature/ResourceLogoTest.php create mode 100644 tests/Feature/ServerCanvasTest.php create mode 100644 tests/Feature/SharedVariableCanvasTest.php create mode 100644 tests/Feature/TagCanvasTest.php create mode 100644 tests/Feature/TeamAvatarTest.php create mode 100644 tests/v4/Browser/DeployCanvasTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 64dad24085..c5f83d759d 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -12,7 +12,7 @@ - `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior. - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. -- Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. +- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. - A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php index 4c15d0726d..80f11802be 100644 --- a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php +++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php @@ -121,8 +121,8 @@ trait HandlesTagsApi return response()->json(['message' => 'Tag not found on resource.'], 404); } + // The tag stays, also without resources. $resource->tags()->detach($tag->id); - $tag->deleteIfOrphaned(); return response()->json(['message' => 'Tag removed.']); } diff --git a/app/Http/Controllers/Api/EnvironmentCanvasController.php b/app/Http/Controllers/Api/EnvironmentCanvasController.php new file mode 100644 index 0000000000..338cbfee51 --- /dev/null +++ b/app/Http/Controllers/Api/EnvironmentCanvasController.php @@ -0,0 +1,264 @@ +findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('view', $environment); + + $user = $request->user(); + $token = $user->currentAccessToken(); + $canEdit = $user->isAdminOfTeam((int) $environment->project->team_id) + && ($token->can('root') || $token->can('write')); + + return response()->json($this->canvas->payload( + $environment, + canReadSensitive: (bool) $request->attributes->get('can_read_sensitive', false), + canEdit: $canEdit, + )); + } + + public function updatePositions(Request $request): JsonResponse + { + $environment = $this->findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('update', $environment); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'positions' => ['present', 'array', 'max:'.self::MAX_POSITIONS], + ]); + if ($response = $this->validationFailedResponse($request, $validator, ['positions'])) { + return $response; + } + + $nodeUuids = $this->canvas->resourceIndex($environment)->keys()->all(); + $positions = collect($request->input('positions'))->only($nodeUuids)->all(); + + $coordinateRule = ['required', 'integer', 'between:-'.EnvironmentCanvas::MAX_COORDINATE.','.EnvironmentCanvas::MAX_COORDINATE]; + $validator = Validator::make(['positions' => $positions], [ + 'positions.*' => ['required', 'array'], + 'positions.*.x' => $coordinateRule, + 'positions.*.y' => $coordinateRule, + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $saved = DB::transaction(function () use ($environment, $positions, $nodeUuids) { + $locked = Environment::query()->lockForUpdate()->findOrFail($environment->id); + $merged = $this->canvas->filterPositions( + array_merge($locked->canvas_positions ?? [], $positions), + $nodeUuids, + ); + $locked->canvas_positions = $merged; + $locked->save(); + + return $merged; + }); + + return response()->json(['positions' => (object) $saved]); + } + + public function storeLink(Request $request): JsonResponse + { + $environment = $this->findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('update', $environment); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'source' => ['required', 'string', 'max:255'], + 'target' => ['required', 'string', 'max:255', 'different:source'], + 'label' => ['nullable', 'string', 'max:64'], + ]); + if ($response = $this->validationFailedResponse($request, $validator, ['source', 'target', 'label'])) { + return $response; + } + + $resources = $this->canvas->resourceIndex($environment); + $source = $resources->get($request->input('source')); + $target = $resources->get($request->input('target')); + $errors = []; + if (! $source) { + $errors['source'] = ['The source must be a resource of this environment.']; + } + if (! $target) { + $errors['target'] = ['The target must be a resource of this environment.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $attributes = [ + 'environment_id' => $environment->id, + 'source_type' => $source['type'], + 'source_id' => $source['id'], + 'target_type' => $target['type'], + 'target_id' => $target['id'], + ]; + $duplicate = ['message' => 'Validation failed.', 'errors' => ['target' => ['These resources are already linked.']]]; + if (ResourceLink::query()->where($attributes)->exists()) { + return response()->json($duplicate, 422); + } + + try { + $link = ResourceLink::create($attributes + [ + 'label' => $this->normalizeLabel($request->input('label')), + 'created_by' => $request->user()->id, + ]); + } catch (UniqueConstraintViolationException) { + return response()->json($duplicate, 422); + } + + return response()->json( + $this->canvas->linkPayload($link, $request->input('source'), $request->input('target')), + 201, + ); + } + + public function updateLink(Request $request): JsonResponse + { + $environment = $this->findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('update', $environment); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'label' => ['present', 'nullable', 'string', 'max:64'], + ]); + if ($response = $this->validationFailedResponse($request, $validator, ['label'])) { + return $response; + } + + $link = $environment->resourceLinks()->whereUuid($request->route('link_uuid'))->first(); + if (! $link) { + return response()->json(['message' => 'Link not found.'], 404); + } + $resources = $this->canvas->resourceIndex($environment); + $uuidOf = fn (string $type, int $id) => $resources->search(fn (array $resource) => $resource['type'] === $type && $resource['id'] === $id); + $sourceUuid = $uuidOf($link->source_type, (int) $link->source_id); + $targetUuid = $uuidOf($link->target_type, (int) $link->target_id); + if ($sourceUuid === false || $targetUuid === false) { + return response()->json(['message' => 'Link not found.'], 404); + } + + $link->update(['label' => $this->normalizeLabel($request->input('label'))]); + + return response()->json($this->canvas->linkPayload($link, $sourceUuid, $targetUuid)); + } + + public function destroyLink(Request $request): JsonResponse + { + $environment = $this->findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('update', $environment); + + $link = $environment->resourceLinks()->whereUuid($request->route('link_uuid'))->first(); + if (! $link) { + return response()->json(['message' => 'Link not found.'], 404); + } + $link->delete(); + + return response()->json(['message' => 'Link deleted.']); + } + + /** + * The environment of the request, within a project of the token's team. + */ + private function findEnvironment(Request $request): Environment|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $project = Project::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + $environmentNameOrUuid = $request->route('environment_name_or_uuid'); + $environment = $project->environments()->whereName($environmentNameOrUuid)->first() + ?? $project->environments()->whereUuid($environmentNameOrUuid)->first(); + if (! $environment) { + return response()->json(['message' => 'Environment not found.'], 404); + } + $environment->setRelation('project', $project); + + return $environment; + } + + /** + * @param array $allowedFields + */ + private function validationFailedResponse(Request $request, ValidatorContract $validator, array $allowedFields): ?JsonResponse + { + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if (! $validator->fails() && empty($extraFields)) { + return null; + } + + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + private function normalizeLabel(mixed $label): ?string + { + $label = trim((string) $label); + + return $label === '' ? null : $label; + } +} diff --git a/app/Http/Controllers/Api/ProjectCanvasController.php b/app/Http/Controllers/Api/ProjectCanvasController.php new file mode 100644 index 0000000000..534cd7f56a --- /dev/null +++ b/app/Http/Controllers/Api/ProjectCanvasController.php @@ -0,0 +1,54 @@ +json($this->canvas->teamPayload((int) $teamId, (bool) $request->attributes->get('can_read_sensitive', false), $this->canEdit($request, (int) $teamId))); + } + + public function show(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $project = Project::query()->whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + $this->authorize('view', $project); + + return response()->json($this->canvas->payload($project, (bool) $request->attributes->get('can_read_sensitive', false), $this->canEdit($request, (int) $project->team_id))); + } + + /** + * Same rule as the environment canvas: team admins with a write token move cards and draw links. + */ + private function canEdit(Request $request, int $teamId): bool + { + $user = $request->user(); + $token = $user->currentAccessToken(); + + return $user->isAdminOfTeam($teamId) && ($token->can('root') || $token->can('write')); + } +} diff --git a/app/Http/Controllers/Api/ServerCanvasController.php b/app/Http/Controllers/Api/ServerCanvasController.php new file mode 100644 index 0000000000..e48b8c4aff --- /dev/null +++ b/app/Http/Controllers/Api/ServerCanvasController.php @@ -0,0 +1,80 @@ +authorize('viewAny', Server::class); + + $servers = Server::query()->whereTeamId($teamId)->with('settings')->orderBy('name')->get(); + + return response()->json($this->canvas->payload($teamId, $servers, null, $this->canReadSensitive($request), $this->canEdit($request, $teamId))); + } + + public function show(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + $this->authorize('view', $server); + + return response()->json($this->canvas->payload($teamId, collect([$server]), $server, $this->canReadSensitive($request), $this->canEdit($request, $teamId))); + } + + /** + * Same rule as the environment canvas: team admins with a write token move cards and draw links. + */ + private function canEdit(Request $request, int $teamId): bool + { + $user = $request->user(); + $token = $user->currentAccessToken(); + + return $user->isAdminOfTeam($teamId) && ($token->can('root') || $token->can('write')); + } + + /** + * Unmanaged Docker containers of one server, as read-only cards for its canvas. + */ + public function containers(string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + $this->authorize('view', $server); + + return response()->json(['nodes' => $this->canvas->unmanagedContainerNodes($server)->all()]); + } + + private function canReadSensitive(Request $request): bool + { + return (bool) $request->attributes->get('can_read_sensitive', false); + } +} diff --git a/app/Http/Controllers/Api/SharedVariableCanvasController.php b/app/Http/Controllers/Api/SharedVariableCanvasController.php new file mode 100644 index 0000000000..718dc6bb76 --- /dev/null +++ b/app/Http/Controllers/Api/SharedVariableCanvasController.php @@ -0,0 +1,199 @@ +scope($request); + $owner = $this->findOwner($scope, $request->route('uuid')); + if ($owner instanceof JsonResponse) { + return $owner; + } + $this->authorize('view', $owner); + + return response()->json($this->canvas->payload($scope, $owner, $this->canEdit($request, $scope, $owner))); + } + + public function storeLink(Request $request): JsonResponse + { + $scope = $this->scope($request); + $owner = $this->findOwner($scope, $request->route('uuid')); + if ($owner instanceof JsonResponse) { + return $owner; + } + $this->authorize('view', $owner); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'source' => ['required', 'string', 'max:255'], + 'target' => ['required', 'string', 'max:255'], + ]); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $variable = $this->findVariable($owner, $request->input('source')); + $resource = $this->canvas->resources($scope, $owner)->get($request->input('target')); + $errors = []; + if (! $variable) { + $errors['source'] = ['The source must be a shared variable of this scope.']; + } + if (! $resource) { + $errors['target'] = ['The target must be a resource that can use this shared variable.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + $this->authorize('manageEnvironment', $resource); + + $existing = $this->canvas->resourceVariables(collect([$resource]))->firstWhere('key', $variable->key); + if ($existing) { + $message = in_array($variable->key, $this->canvas->referencedKeys($scope, $existing->value), true) + ? "{$resource->name} already uses {$variable->key}." + : "{$resource->name} already has a variable named {$variable->key}. Rename or remove it first."; + + return response()->json(['message' => $message, 'errors' => ['target' => [$message]]], 422); + } + + $environmentVariable = new EnvironmentVariable; + $environmentVariable->key = $variable->key; + $environmentVariable->value = $this->canvas->reference($scope, $variable->key); + $environmentVariable->is_multiline = (bool) $variable->is_multiline; + $environmentVariable->is_literal = (bool) $variable->is_literal; + $environmentVariable->resourceable_id = $resource->getKey(); + $environmentVariable->resourceable_type = $resource->getMorphClass(); + $environmentVariable->save(); + + $link = collect($this->canvas->links($scope, collect([$variable]), collect([$resource->uuid => $resource]))) + ->firstWhere('source', $this->canvas->variableNodeId($variable)); + + return response()->json($link, 201); + } + + public function destroyLink(Request $request): JsonResponse + { + $scope = $this->scope($request); + $owner = $this->findOwner($scope, $request->route('uuid')); + if ($owner instanceof JsonResponse) { + return $owner; + } + $this->authorize('view', $owner); + + [$source, $target] = array_pad(explode('|', (string) $request->route('link_id'), 2), 2, ''); + $resource = $this->canvas->resources($scope, $owner)->get($target); + $variable = $this->findVariable($owner, $source); + $key = $variable?->key ?? (str_starts_with($source, 'missing:') ? substr($source, strlen('missing:')) : null); + if (! $resource || blank($key)) { + return response()->json(['message' => 'Link not found.'], 404); + } + $this->authorize('manageEnvironment', $resource); + + // Preview copies of application variables are removed together with the variable. + $references = EnvironmentVariable::query() + ->where('resourceable_type', $resource->getMorphClass()) + ->where('resourceable_id', $resource->getKey()) + ->get() + ->filter(fn (EnvironmentVariable $environmentVariable) => in_array($key, $this->canvas->referencedKeys($scope, $environmentVariable->value), true)); + if ($references->where('is_preview', false)->isEmpty()) { + return response()->json(['message' => 'Link not found.'], 404); + } + $embedded = $references->reject(fn (EnvironmentVariable $environmentVariable) => $this->canvas->isExactReference($scope, $key, $environmentVariable->value)); + if ($embedded->isNotEmpty()) { + $message = "{$embedded->pluck('key')->unique()->join(', ')} on {$resource->name} uses {$this->canvas->reference($scope, $key)} inside a longer value. Edit it on the resource."; + + return response()->json(['message' => $message], 422); + } + if ($this->usesDockerCompose($resource)) { + foreach ($references->pluck('key')->unique() as $environmentKey) { + [$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($environmentKey, $resource->docker_compose); + if ($isUsed) { + return response()->json(['message' => "Cannot remove {$environmentKey} from {$resource->name}. {$reason}"], 422); + } + } + } + + DB::transaction(fn () => $references->each->delete()); + + return response()->json(['message' => 'Link removed.']); + } + + /** + * The team routes have no {scope} segment. + */ + private function scope(Request $request): string + { + return $request->route('scope') ?? 'team'; + } + + private function findOwner(string $scope, ?string $uuid): Model|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $owner = match ($scope) { + 'team' => Team::query()->find($teamId), + 'project' => Project::query()->where('team_id', $teamId)->where('uuid', $uuid)->first(), + 'environment' => Environment::query()->where('uuid', $uuid)->whereHas('project', fn ($query) => $query->where('team_id', $teamId))->with('project')->first(), + 'server' => Server::query()->where('team_id', $teamId)->where('uuid', $uuid)->first(), + default => null, + }; + + return $owner ?? response()->json(['message' => str($scope)->headline().' not found.'], 404); + } + + private function findVariable(Model $owner, ?string $nodeId): ?SharedEnvironmentVariable + { + if (! str_starts_with((string) $nodeId, 'variable:')) { + return null; + } + + return $owner->environment_variables()->whereKey((int) substr($nodeId, strlen('variable:')))->first(); + } + + private function canEdit(Request $request, string $scope, Model $owner): bool + { + $user = $request->user(); + $token = $user->currentAccessToken(); + + return $user->isAdminOfTeam($this->canvas->teamId($scope, $owner)) + && ($token->can('root') || $token->can('write')); + } + + private function usesDockerCompose(Model $resource): bool + { + return $resource instanceof Service + || ($resource instanceof Application && $resource->build_pack === 'dockercompose'); + } +} diff --git a/app/Http/Controllers/Api/TagCanvasController.php b/app/Http/Controllers/Api/TagCanvasController.php new file mode 100644 index 0000000000..09864d2b8d --- /dev/null +++ b/app/Http/Controllers/Api/TagCanvasController.php @@ -0,0 +1,107 @@ +authorize('viewAny', Tag::class); + + $user = $request->user(); + $token = $user->currentAccessToken(); + $canEdit = $user->isAdminOfTeam((int) $teamId) && ($token->can('root') || $token->can('write')); + + return response()->json($this->canvas->payload((int) $teamId, $canEdit)); + } + + public function storeLink(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'source' => ['required', 'string', 'max:255'], + 'target' => ['required', 'string', 'max:255'], + ]); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $tag = $this->findTag((int) $teamId, $request->input('source')); + $resource = $this->canvas->resources((int) $teamId)->get($request->input('target')); + $errors = []; + if (! $tag) { + $errors['source'] = ['The source must be a tag of this team.']; + } + if (! $resource) { + $errors['target'] = ['The target must be a resource of this team.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + $this->authorize('update', $resource); + + if ($resource->tags->contains('id', $tag->id)) { + $message = "{$resource->name} already has the {$tag->name} tag."; + + return response()->json(['message' => $message, 'errors' => ['target' => [$message]]], 422); + } + $resource->tags()->attach($tag->id); + + return response()->json($this->canvas->link($tag, $resource), 201); + } + + public function destroyLink(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + [$source, $target] = array_pad(explode('|', (string) $request->route('link_id'), 2), 2, ''); + $tag = $this->findTag((int) $teamId, $source); + $resource = $this->canvas->resources((int) $teamId)->get($target); + if (! $tag || ! $resource || ! $resource->tags->contains('id', $tag->id)) { + return response()->json(['message' => 'Link not found.'], 404); + } + $this->authorize('update', $resource); + + // The tag stays, also without resources. Delete it on the Tags page. + $resource->tags()->detach($tag->id); + + return response()->json(['message' => 'Link removed.']); + } + + private function findTag(int $teamId, ?string $nodeId): ?Tag + { + if (! str_starts_with((string) $nodeId, 'tag:')) { + return null; + } + + return Tag::query()->where('team_id', $teamId)->where('uuid', substr($nodeId, strlen('tag:')))->first(); + } +} diff --git a/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php index f53cef7c51..d390c26b3a 100644 --- a/app/Http/Controllers/ProfileAvatarController.php +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -2,14 +2,24 @@ namespace App\Http\Controllers; +use App\Models\User; use App\Services\AvatarStorageService; use Illuminate\Http\Response; class ProfileAvatarController extends Controller { - public function __invoke(AvatarStorageService $avatarStorage): Response + public function __invoke(AvatarStorageService $avatarStorage, ?int $user_id = null): Response { - $contents = $avatarStorage->contents(auth()->user()); + $user = auth()->user(); + + if ($user_id !== null && $user_id !== $user->id) { + $user = User::query() + ->whereKey($user_id) + ->whereHas('teams', fn ($query) => $query->whereIn('teams.id', auth()->user()->teams()->select('teams.id'))) + ->firstOrFail(); + } + + $contents = $avatarStorage->contents($user); abort_if($contents === null, 404); return response($contents, 200, [ diff --git a/app/Http/Controllers/TeamAvatarController.php b/app/Http/Controllers/TeamAvatarController.php new file mode 100644 index 0000000000..3dfed60b27 --- /dev/null +++ b/app/Http/Controllers/TeamAvatarController.php @@ -0,0 +1,22 @@ +user()->teams()->whereKey($team_id)->firstOrFail(); + $contents = $avatarStorage->teamContents($team); + + abort_if($contents === null, 404); + + return response($contents, 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=31536000, immutable', + ]); + } +} diff --git a/app/Http/Middleware/UseSessionTeamApiToken.php b/app/Http/Middleware/UseSessionTeamApiToken.php new file mode 100644 index 0000000000..821b4e5f0f --- /dev/null +++ b/app/Http/Middleware/UseSessionTeamApiToken.php @@ -0,0 +1,51 @@ +user(); + $teamId = $request->header('X-Canvas-Team'); + + if (! $user) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + if (! is_string($teamId) || ! ctype_digit($teamId)) { + return response()->json(['message' => 'Missing canvas team.'], 400); + } + $teamId = (int) $teamId; + + $abilities = $user->isAdminOfTeam($teamId) + ? ['read', 'write', 'deploy', 'read:sensitive'] + : ['read']; + + $token = new PersonalAccessToken; + $token->forceFill([ + 'name' => 'deploy-canvas-session', + 'team_id' => $teamId, + 'abilities' => $abilities, + ]); + $user->withAccessToken($token); + + return $next($request); + } +} diff --git a/app/Livewire/Concerns/CreatesEnvironments.php b/app/Livewire/Concerns/CreatesEnvironments.php new file mode 100644 index 0000000000..1212cb70f0 --- /dev/null +++ b/app/Livewire/Concerns/CreatesEnvironments.php @@ -0,0 +1,50 @@ + ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + + public function submit() + { + try { + $this->authorize('update', $this->project); + $this->validate(); + $environment = Environment::create([ + 'name' => $this->name, + 'project_id' => $this->project->id, + 'uuid' => new_public_id(), + ]); + + return redirectRoute($this, 'project.resource.index', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $environment->uuid, + ]); + } catch (\Throwable $e) { + handleError($e, $this); + } + } +} diff --git a/app/Livewire/Concerns/CreatesTags.php b/app/Livewire/Concerns/CreatesTags.php new file mode 100644 index 0000000000..0b61825511 --- /dev/null +++ b/app/Livewire/Concerns/CreatesTags.php @@ -0,0 +1,39 @@ +authorize('create', Tag::class); + $this->validate( + ['newTagName' => ['required', 'string', 'min:2', 'max:255', 'regex:/^\S+$/']], + ['newTagName.regex' => 'A tag name cannot contain spaces.'], + ); + $name = strtolower(trim(strip_tags($this->newTagName))); + if (mb_strlen($name) < 2) { + throw ValidationException::withMessages(['newTagName' => 'The tag name must be at least 2 characters after sanitization.']); + } + if (Tag::ownedByCurrentTeam()->where('name', $name)->exists()) { + throw ValidationException::withMessages(['newTagName' => 'A tag with this name already exists.']); + } + Tag::create(['name' => $name, 'team_id' => currentTeam()->id]); + + return $this->redirectRoute('tags.show', ['tagName' => $name], navigate: true); + } catch (ValidationException $e) { + throw $e; + } catch (\Exception $e) { + return handleError($e, $this); + } + } +} diff --git a/app/Livewire/Concerns/RemembersCanvasView.php b/app/Livewire/Concerns/RemembersCanvasView.php new file mode 100644 index 0000000000..d8bad86d4d --- /dev/null +++ b/app/Livewire/Concerns/RemembersCanvasView.php @@ -0,0 +1,72 @@ +rememberView($page, $view); + } + } + + /** + * @param 'servers'|'server_resources'|'projects'|'project'|'environment'|'tags'|'team_variables'|'project_variables'|'environment_variables'|'server_variables' $page + * @param 'table'|'grid'|'list'|'canvas' $view + */ + protected function rememberView(string $page, string $view): void + { + Cookie::queue(Cookie::forever($this->viewCookieName($page), $view)); + } + + /** + * List page: true when the user last used the canvas. A ?view=table|grid|list link + * (the switcher on the canvas page) picks the list again and is remembered. + * + * @param 'servers'|'server_resources'|'projects'|'project'|'environment'|'tags'|'team_variables'|'project_variables'|'environment_variables'|'server_variables' $page + */ + protected function prefersCanvasView(string $page): bool + { + $view = request()->query('view'); + if (in_array($view, self::LIST_VIEWS, true)) { + $this->rememberView($page, $view); + + return false; + } + + return request()->cookie($this->viewCookieName($page)) === 'canvas'; + } + + /** + * List page: the remembered list view (table or grid), or $default. + */ + protected function listView(string $page, string $default): string + { + $view = request()->query('view') ?? request()->cookie($this->viewCookieName($page)); + + return in_array($view, ['table', 'grid'], true) ? $view : $default; + } + + private function viewCookieName(string $page): string + { + return 'coolify_'.auth()->id().'_'.$page.'_view'; + } +} diff --git a/app/Livewire/DeployCanvas.php b/app/Livewire/DeployCanvas.php new file mode 100644 index 0000000000..3f2c6d4164 --- /dev/null +++ b/app/Livewire/DeployCanvas.php @@ -0,0 +1,83 @@ +teamId = currentTeam()->id; + } + + public function render(): View + { + return view('livewire.deploy-canvas', [ + 'serviceTemplates' => $this->serviceTemplates(), + 'serviceLogos' => $this->serviceLogos(), + 'resourceLogos' => $this->resourceLogos(), + // Only shows the "New project / environment" rows; the API checks again. + 'canAddProjects' => auth()->user()->can('create', Project::class), + ])->layout('layouts.app', ['fullBleed' => true]); + } + + /** + * The versioned URLs of the logos in public/svgs/resources for the step icons: name => URL. + * + * @return array + */ + private function resourceLogos(): array + { + return collect(glob(public_path('svgs/resources/*.svg')) ?: []) + ->mapWithKeys(fn (string $path): array => [basename($path, '.svg') => resource_logo_url(basename($path, '.svg'))]) + ->all(); + } + + /** + * Service template logos for the Service step icon, as resource_logo() shows them: + * template key => [logo URL, CDN fallback URL]. Templates without a logo are left out. + * + * @return array + */ + private function serviceLogos(): array + { + return get_service_templates() + ->map(fn ($template): mixed => data_get($template, 'logo')) + ->filter(fn (mixed $logo): bool => is_string($logo) && $logo !== '' && basename($logo) !== 'default.webp') + ->map(function (string $logo): array { + $urls = service_logo_urls($logo); + + return [$urls['logo'], $urls['logo_cdn_url']]; + }) + ->all(); + } + + /** + * One-click service templates for the Service step: the keys that the + * services API accepts, with the names of the New resource page. + * + * @return list + */ + private function serviceTemplates(): array + { + return get_service_templates()->keys() + ->map(fn ($key): array => [(string) $key, str((string) $key)->headline()->value()]) + ->sortBy(fn (array $template): string => strtolower($template[1])) + ->values() + ->all(); + } +} diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 035349b728..3e9733f063 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -178,6 +178,7 @@ class GlobalSearch extends Component 'new github app' => 'source', 'new github' => 'source', 'new source' => 'source', + 'new canvas' => 'canvas', // Applications - Git-based 'new public' => 'public', @@ -1175,6 +1176,18 @@ class GlobalSearch extends Component ]); } + // Canvas - deploy resources visually; can be used if user has createAnyResource permission + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'Canvas', + 'description' => 'Deploy resources visually on the deploy canvas', + 'quickcommand' => '(type: new canvas)', + 'type' => 'canvas', + 'category' => 'Quick Actions', + 'route' => 'deploy-canvas', + ]); + } + // === Applications Category === if ($user->can('createAnyResource')) { @@ -1354,6 +1367,13 @@ class GlobalSearch extends Component return; } + // If it has a route, it's a page-based quick action + if (isset($item['route'])) { + $this->dispatch('closeSearchModal'); + + return redirectRoute($this, $item['route']); + } + // If it has a component, it's a modal-based resource // Close search modal and open the appropriate creation modal if (isset($item['component'])) { diff --git a/app/Livewire/Project/EnvironmentCanvas.php b/app/Livewire/Project/EnvironmentCanvas.php new file mode 100644 index 0000000000..22309d2e32 --- /dev/null +++ b/app/Livewire/Project/EnvironmentCanvas.php @@ -0,0 +1,41 @@ +project = Project::ownedByCurrentTeam()->whereUuid($project_uuid)->firstOrFail(); + $this->environment = $this->project->environments()->whereUuid($environment_uuid)->firstOrFail(); + $this->authorize('view', $this->environment); + $this->rememberView('environment', 'canvas'); + } + + public function render(): View + { + return view('livewire.project.environment-canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 038d8dea33..e3ba5f4f3c 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -2,16 +2,25 @@ namespace App\Livewire\Project; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Project; use Illuminate\Contracts\View\View; use Livewire\Component; class Index extends Component { + use RemembersCanvasView; + public $projects; public function mount(): void { + if ($this->prefersCanvasView('projects')) { + $this->redirectRoute('project.canvas.index', navigate: true); + + return; + } + // Only load what the page renders. Servers/private keys were previously // hydrated into public Livewire state but never used by the view. $this->projects = Project::ownedByCurrentTeam() diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 629a7e56d9..9986bfa1f5 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -156,28 +156,28 @@ class Select extends Component 'name' => 'Public Git Repository', 'description' => 'Deploy any public Git repository. Coolify builds it from source, no credentials required.', 'documentation' => 'https://coolify.io/docs/applications/ci-cd', - 'logo' => asset('svgs/resources/public-repo.svg'), + 'logo' => resource_logo_url('public-repo'), ], [ 'id' => 'private-deploy-key', 'name' => 'Private Git Repository (with Deploy Key)', 'description' => 'Deploy a private repository over SSH with a repository-scoped deploy key. Set the URL and branch manually, no automatic deploys.', 'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/deploy-key', - 'logo' => asset('svgs/resources/deploy-key.svg'), + 'logo' => resource_logo_url('deploy-key'), ], [ 'id' => 'private-gh-app', 'name' => 'Git Repository (with GitHub App)', 'description' => 'Deploy public or private GitHub repositories through a GitHub App, with automatic webhooks and pull request previews.', 'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/setup-app', - 'logo' => asset('svgs/resources/github-app.svg'), - 'logoDark' => asset('svgs/resources/github-app-dark.svg'), + 'logo' => resource_logo_url('github-app'), + 'logoDark' => resource_logo_url('github-app-dark'), ], [ 'id' => 'private-gitlab-app', 'name' => 'Git Repository (with GitLab App)', 'description' => 'Deploy public or private GitLab projects through a GitLab App, with automatic webhooks and merge request previews.', - 'logo' => asset('svgs/resources/gitlab-app.svg'), + 'logo' => resource_logo_url('gitlab-app'), ], ]; $dockerBasedApplications = [ @@ -186,21 +186,21 @@ class Select extends Component 'name' => 'Dockerfile', 'description' => 'Deploy an application using a Dockerfile, without a Git repository.', 'documentation' => 'https://coolify.io/docs/applications/build-packs/dockerfile', - 'logo' => asset('svgs/resources/dockerfile.svg'), + 'logo' => resource_logo_url('dockerfile'), ], [ 'id' => 'docker-compose-empty', 'name' => 'Docker Compose', 'description' => 'Deploy a multi-container application using a Docker Compose file, without a Git repository.', 'documentation' => 'https://coolify.io/docs/applications/build-packs/docker-compose', - 'logo' => asset('svgs/resources/docker-compose.svg'), + 'logo' => resource_logo_url('docker-compose'), ], [ 'id' => 'docker-image', 'name' => 'Docker Image', 'description' => 'Deploy an application using a prebuilt image from any Docker registry, without a Git repository.', 'documentation' => 'https://coolify.io/docs/applications', - 'logo' => asset('svgs/resources/docker-image.svg'), + 'logo' => resource_logo_url('docker-image'), ], ]; $databases = [ @@ -208,58 +208,58 @@ class Select extends Component 'id' => 'postgresql', 'name' => 'PostgreSQL', 'description' => 'A relational database with strong SQL standards support and extensibility.', - 'logo' => asset('svgs/resources/postgres.svg'), + 'logo' => resource_logo_url('postgres'), ], [ 'id' => 'mysql', 'name' => 'MySQL', 'description' => 'A relational database for web and general-purpose applications.', - 'logo' => asset('svgs/resources/mysql.svg'), + 'logo' => resource_logo_url('mysql'), ], [ 'id' => 'mariadb', 'name' => 'MariaDB', 'description' => 'A relational database and drop-in replacement for MySQL.', - 'logo' => asset('svgs/resources/mariadb.svg'), + 'logo' => resource_logo_url('mariadb'), ], [ 'id' => 'redis', 'name' => 'Redis', 'description' => 'An in-memory key-value store used as a database, cache, and message broker.', - 'logo' => asset('svgs/resources/redis.svg'), + 'logo' => resource_logo_url('redis'), ], [ 'id' => 'keydb', 'name' => 'KeyDB', 'description' => 'A multithreaded, Redis-compatible in-memory store.', - 'logo' => asset('svgs/resources/keydb.svg'), - 'logoDark' => asset('svgs/resources/keydb-dark.svg'), + 'logo' => resource_logo_url('keydb'), + 'logoDark' => resource_logo_url('keydb-dark'), ], [ 'id' => 'dragonfly', 'name' => 'Dragonfly', 'description' => 'An in-memory datastore compatible with Redis and Memcached.', - 'logo' => asset('svgs/resources/dragonfly.svg'), - 'logoDark' => asset('svgs/resources/dragonfly-dark.svg'), + 'logo' => resource_logo_url('dragonfly'), + 'logoDark' => resource_logo_url('dragonfly-dark'), ], [ 'id' => 'mongodb', 'name' => 'MongoDB', 'description' => 'A document-oriented NoSQL database that stores JSON-like documents.', - 'logo' => asset('svgs/resources/mongodb.svg'), + 'logo' => resource_logo_url('mongodb'), ], [ 'id' => 'clickhouse', 'name' => 'ClickHouse', 'description' => 'A column-oriented database for real-time analytics over large datasets.', - 'logo' => asset('svgs/resources/clickhouse.svg'), + 'logo' => resource_logo_url('clickhouse'), ], [ 'id' => 'sqlite', 'name' => 'SQLite', 'description' => 'A lightweight relational database stored in a single file.', - 'logo' => asset('svgs/resources/sqlite.svg'), + 'logo' => resource_logo_url('sqlite'), 'experimental' => true, ], diff --git a/app/Livewire/Project/ProjectCanvas.php b/app/Livewire/Project/ProjectCanvas.php new file mode 100644 index 0000000000..d0e3e26c5c --- /dev/null +++ b/app/Livewire/Project/ProjectCanvas.php @@ -0,0 +1,38 @@ +project = Project::ownedByCurrentTeam()->whereUuid($project_uuid)->firstOrFail(); + $this->authorize('view', $this->project); + $this->rememberView('project', 'canvas'); + } + + public function render(): View + { + return view('livewire.project.project-canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Project/ProjectsCanvas.php b/app/Livewire/Project/ProjectsCanvas.php new file mode 100644 index 0000000000..93cf54dce7 --- /dev/null +++ b/app/Livewire/Project/ProjectsCanvas.php @@ -0,0 +1,37 @@ +authorize('viewAny', Project::class); + $this->teamId = currentTeam()->id; + $this->rememberView('projects', 'canvas'); + } + + public function render(): View + { + return view('livewire.project.projects-canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index dca586fc0c..90c42c581d 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -2,13 +2,17 @@ namespace App\Livewire\Project\Resource; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Environment; use App\Models\Project; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Blade; use Livewire\Component; class Index extends Component { + use RemembersCanvasView; + public Project $project; public Environment $environment; @@ -55,6 +59,12 @@ class Index extends Component ->where('uuid', request()->route('environment_uuid')) ->firstOrFail(); + if ($this->prefersCanvasView('environment')) { + $this->redirectRoute('project.environment.canvas', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid], navigate: true); + + return; + } + $this->project = $project; // Load projects and environments for breadcrumb navigation @@ -201,6 +211,7 @@ class Index extends Component 'maxRestartCount' => $item->max_restart_count ?? 0, 'server_status' => $item->server_status ?? null, 'hrefLink' => $item->hrefLink ?? '', + 'logoHtml' => Blade::render('', ['resource' => $item]), 'destination' => [ 'server' => [ 'name' => $item->destination?->server?->name ?? 'Unknown', diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 2e8be77ddf..6dc835fa95 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -10,6 +10,7 @@ use App\Models\Project; use App\Models\Server; use App\Models\Service; use App\Models\SharedEnvironmentVariable; +use App\Services\SharedVariableCanvas; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; @@ -404,6 +405,15 @@ class Show extends Component ); } + /** + * Shared variables only: the connect envs canvas with this variable selected. + */ + #[Computed] + public function connectLink(): ?string + { + return $this->isSharedVariable ? app(SharedVariableCanvas::class)->connectLink($this->env) : null; + } + #[Computed] public function availableSharedVariables(): array { diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 9305d20a63..99daee1b7f 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -88,11 +88,10 @@ class Tags extends Component { try { $this->authorize('update', $this->resource); + // The tag stays, also without resources. Delete it on the Tags page. $this->resource->tags()->detach($id); - $found_more_tags = $this->resourceTags()->find($id); - $found_more_tags?->deleteIfOrphaned(); $this->refresh(); - $this->dispatch('success', 'Tag deleted.'); + $this->dispatch('success', 'Tag removed.'); } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index ee73e72f93..a055904d51 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -2,9 +2,10 @@ namespace App\Livewire\Project; +use App\Livewire\Concerns\CreatesEnvironments; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Environment; use App\Models\Project; -use App\Support\ValidationPatterns; use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -12,26 +13,11 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use CreatesEnvironments; + use RemembersCanvasView; public Project $project; - public string $name; - - public ?string $description = null; - - protected function rules(): array - { - return [ - 'name' => ValidationPatterns::nameRules(), - 'description' => ValidationPatterns::descriptionRules(), - ]; - } - - protected function messages(): array - { - return ValidationPatterns::combinedMessages(); - } - public function mount(string $project_uuid) { try { @@ -55,31 +41,15 @@ class Show extends Component ->orderBy('created_at'), ]) ->firstOrFail(); + + if ($this->prefersCanvasView('project')) { + return $this->redirectRoute('project.canvas', ['project_uuid' => $this->project->uuid], navigate: true); + } } catch (\Throwable $e) { return handleError($e, $this); } } - public function submit() - { - try { - $this->authorize('update', $this->project); - $this->validate(); - $environment = Environment::create([ - 'name' => $this->name, - 'project_id' => $this->project->id, - 'uuid' => new_public_id(), - ]); - - return redirectRoute($this, 'project.resource.index', [ - 'project_uuid' => $this->project->uuid, - 'environment_uuid' => $environment->uuid, - ]); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - public function navigateToEnvironment($projectUuid, $environmentUuid) { return redirectRoute($this, 'project.resource.index', [ diff --git a/app/Livewire/Server/Canvas.php b/app/Livewire/Server/Canvas.php new file mode 100644 index 0000000000..dd5c64fb46 --- /dev/null +++ b/app/Livewire/Server/Canvas.php @@ -0,0 +1,46 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('view', $this->server); + $this->teamId = $this->server->team_id; + $this->rememberView('server_resources', 'canvas'); + } else { + $this->teamId = currentTeam()->id; + $this->rememberView('servers', 'canvas'); + } + } + + public function render(): View + { + return view('livewire.server.canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Server/DockerRegistries/ServerRegistries.php b/app/Livewire/Server/DockerRegistries/ServerRegistries.php index 7f6e169662..b3d2acc152 100644 --- a/app/Livewire/Server/DockerRegistries/ServerRegistries.php +++ b/app/Livewire/Server/DockerRegistries/ServerRegistries.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server\DockerRegistries; use App\Models\Server; use App\Services\DockerRegistryLogins; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\View\View; use Livewire\Attributes\Lazy; use Livewire\Attributes\Locked; use Livewire\Attributes\On; @@ -104,13 +105,9 @@ class ServerRegistries extends Component $this->loadRegistries(); } - public function placeholder(): string + public function placeholder(): View { - return <<<'HTML' -
- Loading registries... -
- HTML; + return view('livewire.server.docker-registries.server-registries-placeholder'); } public function render() diff --git a/app/Livewire/Server/Index.php b/app/Livewire/Server/Index.php index eb832d72f7..a7d30f89ad 100644 --- a/app/Livewire/Server/Index.php +++ b/app/Livewire/Server/Index.php @@ -2,16 +2,25 @@ namespace App\Livewire\Server; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Server; use Illuminate\Database\Eloquent\Collection; use Livewire\Component; class Index extends Component { + use RemembersCanvasView; + public ?Collection $servers = null; - public function mount() + public function mount(): void { + if ($this->prefersCanvasView('servers')) { + $this->redirectRoute('server.canvas.index', navigate: true); + + return; + } + $this->servers = Server::ownedByCurrentTeamCached(); } diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index dd62a9e776..86f87cc13f 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Server; use App\Support\ValidationPatterns; use App\Traits\ListensToTeamChannel; @@ -14,6 +15,7 @@ class Resources extends Component { use AuthorizesRequests; use ListensToTeamChannel; + use RemembersCanvasView; use WithPagination; public int $perPage = 10; @@ -144,6 +146,9 @@ class Resources extends Component if (is_null($this->server)) { return redirect()->route('server.index'); } + if ($this->prefersCanvasView('server_resources')) { + return $this->redirectRoute('server.canvas', ['server_uuid' => $this->server->uuid], navigate: true); + } } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/SharedVariables/Canvas.php b/app/Livewire/SharedVariables/Canvas.php new file mode 100644 index 0000000000..44304cafce --- /dev/null +++ b/app/Livewire/SharedVariables/Canvas.php @@ -0,0 +1,147 @@ +teamId = currentTeam()->id; + $this->projectUuid = $project_uuid; + $this->environmentUuid = $environment_uuid; + $this->serverUuid = $server_uuid; + $this->scope = match (true) { + $server_uuid !== null => 'server', + $environment_uuid !== null => 'environment', + $project_uuid !== null => 'project', + default => 'team', + }; + $owner = $this->owner(); + $this->authorize('view', $owner); + + $this->endpoint = $this->scope === 'team' + ? '/shared-variables/team/canvas' + : "/shared-variables/{$this->scope}/{$owner->uuid}/canvas"; + $this->listUrl = $canvas->pageLink($this->scope, $owner); + $this->rememberView("{$this->scope}_variables", 'canvas'); + } + + /** + * Saves a variable from the "Add variable" modal (the Add component dispatches saveKey), + * then reloads the canvas so it shows the new card. + * + * @param array{key: string, value: string, is_multiline: bool, is_literal: bool, comment?: ?string} $data + */ + #[On('saveKey')] + public function saveKey(array $data) + { + try { + $owner = $this->owner(); + $this->authorize('update', $owner); + + if ($this->scope === 'server' && in_array($data['key'], ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])) { + throw new \Exception('Cannot create predefined variable.'); + } + if ($owner->environment_variables()->where('key', $data['key'])->exists()) { + throw new \Exception('Variable already exists.'); + } + $owner->environment_variables()->create([ + 'key' => $data['key'], + 'value' => $data['value'], + 'is_multiline' => $data['is_multiline'], + 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, + 'type' => $this->scope, + 'team_id' => $this->ownerTeamId($owner), + ]); + + return $this->redirect($this->canvasUrl(), navigate: true); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + /** + * The team, project, environment, or server whose variables the canvas shows, scoped to the page's team. + */ + private function owner(): Team|Project|Environment|Server + { + $teamId = $this->teamId; + + return match ($this->scope) { + 'server' => Server::query()->where('team_id', $teamId)->where('uuid', $this->serverUuid)->firstOrFail(), + 'environment' => Environment::query()->where('uuid', $this->environmentUuid) + ->whereHas('project', fn ($query) => $query->where('team_id', $teamId)->where('uuid', $this->projectUuid))->with('project')->firstOrFail(), + 'project' => Project::query()->where('team_id', $teamId)->where('uuid', $this->projectUuid)->firstOrFail(), + default => Team::query()->findOrFail($teamId), + }; + } + + private function ownerTeamId(Team|Project|Environment|Server $owner): int + { + return match (true) { + $owner instanceof Team => $owner->id, + $owner instanceof Environment => $owner->project->team_id, + default => $owner->team_id, + }; + } + + private function canvasUrl(): string + { + return match ($this->scope) { + 'server' => route('shared-variables.server.canvas', ['server_uuid' => $this->serverUuid]), + 'environment' => route('shared-variables.environment.canvas', ['project_uuid' => $this->projectUuid, 'environment_uuid' => $this->environmentUuid]), + 'project' => route('shared-variables.project.canvas', ['project_uuid' => $this->projectUuid]), + default => route('shared-variables.team.canvas'), + }; + } + + public function render(): View + { + return view('livewire.shared-variables.canvas', [ + 'canAddVariables' => auth()->user()->can('update', $this->owner()), + ])->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 5e07fc4f72..73c023f321 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\SharedVariables\Environment; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Application; use App\Models\Project; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -11,6 +12,7 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use RemembersCanvasView; public Project $project; @@ -59,6 +61,9 @@ class Show extends Component $this->project = Project::ownedByCurrentTeam()->where('uuid', $projectUuid)->firstOrFail(); $this->environment = $this->project->environments()->where('uuid', $environmentUuid)->firstOrFail(); + if ($this->prefersCanvasView('environment_variables')) { + return $this->redirectRoute('shared-variables.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid], navigate: true); + } $this->getDevView(); } diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index af06ce7d7f..73590db941 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\SharedVariables\Project; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Project; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -10,6 +11,7 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use RemembersCanvasView; public Project $project; @@ -53,6 +55,9 @@ class Show extends Component return redirect()->route('dashboard'); } $this->project = $project; + if ($this->prefersCanvasView('project_variables')) { + return $this->redirectRoute('shared-variables.project.canvas', ['project_uuid' => $project->uuid], navigate: true); + } $this->getDevView(); } diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php index ee89b2dcd3..e775c50257 100644 --- a/app/Livewire/SharedVariables/Server/Show.php +++ b/app/Livewire/SharedVariables/Server/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\SharedVariables\Server; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -10,6 +11,7 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use RemembersCanvasView; public Server $server; @@ -58,6 +60,9 @@ class Show extends Component } $this->authorize('view', $server); $this->server = $server; + if ($this->prefersCanvasView('server_variables')) { + return $this->redirectRoute('shared-variables.server.canvas', ['server_uuid' => $server->uuid], navigate: true); + } $this->getDevView(); } diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index b7052590b2..034e71c223 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -2,6 +2,7 @@ namespace App\Livewire\SharedVariables\Team; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Team; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -10,6 +11,7 @@ use Livewire\Component; class Index extends Component { use AuthorizesRequests; + use RemembersCanvasView; public Team $team; @@ -46,6 +48,9 @@ class Index extends Component public function mount() { + if ($this->prefersCanvasView('team_variables')) { + return $this->redirectRoute('shared-variables.team.canvas', navigate: true); + } $this->team = currentTeam(); $this->getDevView(); } diff --git a/app/Livewire/SwitchTeam.php b/app/Livewire/SwitchTeam.php index 3df399b100..11d7141537 100644 --- a/app/Livewire/SwitchTeam.php +++ b/app/Livewire/SwitchTeam.php @@ -9,6 +9,8 @@ class SwitchTeam extends Component { public string $selectedTeamId = 'default'; + protected $listeners = ['team-avatar-updated' => '$refresh']; + public function mount() { $this->selectedTeamId = auth()->user()->currentTeam()->id; diff --git a/app/Livewire/Tags/Canvas.php b/app/Livewire/Tags/Canvas.php new file mode 100644 index 0000000000..d8474e5d2c --- /dev/null +++ b/app/Livewire/Tags/Canvas.php @@ -0,0 +1,39 @@ +authorize('viewAny', Tag::class); + $this->teamId = currentTeam()->id; + $this->rememberView('tags', 'canvas'); + } + + public function render(): View + { + return view('livewire.tags.canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Tags/Show.php b/app/Livewire/Tags/Show.php index 381fb0462c..e2f4980d75 100644 --- a/app/Livewire/Tags/Show.php +++ b/app/Livewire/Tags/Show.php @@ -3,6 +3,8 @@ namespace App\Livewire\Tags; use App\Http\Controllers\Api\DeployController; +use App\Livewire\Concerns\CreatesTags; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\ApplicationDeploymentQueue; use App\Models\Tag; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -15,6 +17,8 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use CreatesTags; + use RemembersCanvasView; #[Locked] public ?string $tagName = null; @@ -39,6 +43,10 @@ class Show extends Component public function mount() { + if (str($this->tagName)->isEmpty() && $this->prefersCanvasView('tags')) { + return $this->redirectRoute('tags.canvas', navigate: true); + } + try { $this->tags = Tag::ownedByCurrentTeam() ->withCount(['applications', 'services']) @@ -115,6 +123,25 @@ class Show extends Component } } + /** + * Creates a tag without resources. Tag resources on the tags canvas or on the resource. + */ + /** + * Deletes the tag and removes it from every resource. + */ + public function deleteTag() + { + try { + $this->authorize('delete', $this->tag); + // taggables rows cascade-delete via FK on tag_id + $this->tag->delete(); + + return $this->redirectRoute('tags.show', navigate: true); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.tags.show', [ diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 9ae34b5610..58b940f932 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -4,13 +4,16 @@ namespace App\Livewire\Team; use App\Models\Team; use App\Models\TeamInvitation; +use App\Services\TeamAvatarStorageService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; +use Livewire\WithFileUploads; class Index extends Component { use AuthorizesRequests; + use WithFileUploads; public $invitations = []; @@ -25,6 +28,8 @@ class Index extends Component public bool $is_build_server_fallback_enabled = true; + public $avatar; + protected function rules(): array { return [ @@ -84,6 +89,40 @@ class Index extends Component } } + public function uploadAvatar(TeamAvatarStorageService $avatarStorage): bool + { + try { + $this->authorize('update', $this->team); + $this->validate([ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120', 'dimensions:max_width=6000,max_height=6000'], + ]); + $avatarStorage->storeTeam($this->team, $this->avatar); + $this->reset('avatar'); + refreshSession(); + $this->dispatch('team-avatar-updated'); + $this->dispatch('success', 'Team avatar updated.'); + + return true; + } catch (\Throwable $e) { + handleError($e, $this); + + return false; + } + } + + public function removeAvatar(TeamAvatarStorageService $avatarStorage): void + { + try { + $this->authorize('update', $this->team); + $avatarStorage->deleteTeam($this->team); + refreshSession(); + $this->dispatch('team-avatar-updated'); + $this->dispatch('success', 'Team avatar removed.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function render() { return view('livewire.team.index'); diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 0120461040..5521bd31c5 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -6,6 +6,7 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use OpenApi\Attributes as OA; #[OA\Schema( @@ -33,6 +34,18 @@ class Environment extends BaseModel 'uuid', ]; + /** + * Moving cards on the environment canvas is layout, not a change worth an audit event. + */ + protected array $auditExclude = ['canvas_positions']; + + protected function casts(): array + { + return [ + 'canvas_positions' => 'array', + ]; + } + protected static function booted() { static::deleting(function ($environment) { @@ -148,6 +161,11 @@ class Environment extends BaseModel return $this->hasMany(Service::class); } + public function resourceLinks(): HasMany + { + return $this->hasMany(ResourceLink::class); + } + protected function customizeName($value) { return str($value)->lower()->trim()->replace('/', '-')->toString(); diff --git a/app/Models/ResourceLink.php b/app/Models/ResourceLink.php new file mode 100644 index 0000000000..a8f0e823e2 --- /dev/null +++ b/app/Models/ResourceLink.php @@ -0,0 +1,37 @@ +belongsTo(Environment::class); + } + + public function source(): MorphTo + { + return $this->morphTo(); + } + + public function target(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index 200806a830..1f413491bd 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -255,6 +255,15 @@ class Service extends BaseModel return 'starting:unhealthy'; } + return $this->containerStatus(); + } + + /** + * The aggregate status of the stored container statuses, without the running start check. + * Callers that check starting services in bulk use it to avoid a query per service. + */ + public function containerStatus(): ?string + { $applications = $this->applications; $databases = $this->databases; diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 30844b2bb6..f6745e04d3 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -4,7 +4,6 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; -use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; #[OA\Schema( @@ -36,13 +35,6 @@ class Tag extends BaseModel return Tag::whereTeamId(currentTeam()->id)->orderBy('name'); } - public function deleteIfOrphaned(): void - { - if (DB::table('taggables')->where('tag_id', $this->id)->doesntExist()) { - $this->delete(); - } - } - public function applications() { return $this->morphedByMany(Application::class, 'taggable'); diff --git a/app/Models/Team.php b/app/Models/Team.php index 349c5b5681..a6a6f82dae 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -10,6 +10,7 @@ use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; use App\Notifications\Server\Unreachable; +use App\Services\TeamAvatarStorageService; use App\Traits\Auditable; use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; @@ -123,6 +124,10 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen $s3->delete(); } }); + + static::deleted(function (Team $team) { + app(TeamAvatarStorageService::class)->deleteTeamFile($team); + }); } public static function serverLimitReached(?Team $team = null) diff --git a/app/Services/EnvironmentCanvas.php b/app/Services/EnvironmentCanvas.php new file mode 100644 index 0000000000..ff42ebf6bd --- /dev/null +++ b/app/Services/EnvironmentCanvas.php @@ -0,0 +1,380 @@ + '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; + } +} diff --git a/app/Services/ProjectCanvas.php b/app/Services/ProjectCanvas.php new file mode 100644 index 0000000000..20e537a8c4 --- /dev/null +++ b/app/Services/ProjectCanvas.php @@ -0,0 +1,106 @@ +>, nodes: array>, links: array>, positions: array} + */ + public function teamPayload(int $teamId, bool $canReadSensitive, bool $canEdit = false): array + { + $projects = Project::query()->where('team_id', $teamId)->orderBy('name')->get(['id', 'uuid', 'name', 'team_id']); + $environments = Environment::query()->whereIn('project_id', $projects->pluck('id')) + ->get(['id', 'uuid', 'name', 'project_id', 'canvas_positions']); + $resources = $this->servers->loadResources($environments->pluck('id')); + $links = ResourceLink::query()->whereIn('environment_id', $environments->pluck('id'))->orderBy('id')->get(); + $graph = $this->canvas->graph($resources, $links, $canReadSensitive); + + $grouping = $resources->map(fn (Model $resource) => [ + 'zone' => $resource->environment->project->uuid, + 'extra_zones' => [], + 'context' => $resource->environment->name, + 'context_link' => route('project.resource.index', ['project_uuid' => $resource->environment->project->uuid, 'environment_uuid' => $resource->environment->uuid]), + ...$this->owner($resource), + ]); + + return [ + 'title' => 'Projects', + 'title_link' => route('project.index'), + 'can_edit' => $canEdit, + // Same grouped layout as the server canvas, with projects as the zones. + 'layout' => 'server', + 'zones' => $projects->map(fn (Project $project) => [ + 'id' => $project->uuid, + 'name' => $project->name, + 'icon' => 'projects', + 'link' => route('project.show', ['project_uuid' => $project->uuid]), + ])->values()->all(), + 'nodes' => collect($graph['nodes'])->map(fn (array $node) => [...$node, ...$grouping->get($node['id'])])->values()->all(), + 'links' => $graph['links'], + 'positions' => $this->canvas->filterPositions( + $environments->reduce(fn (array $positions, Environment $environment) => [...$positions, ...($environment->canvas_positions ?? [])], []), + $resources->keys()->all(), + ), + ]; + } + + /** + * @return array{title: string, title_link: string, can_edit: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array} + */ + public function payload(Project $project, bool $canReadSensitive, bool $canEdit = false): array + { + $environments = $project->environments()->get(['id', 'uuid', 'name', 'project_id', 'canvas_positions']); + $resources = $this->servers->loadResources($environments->pluck('id')); + $links = ResourceLink::query()->whereIn('environment_id', $environments->pluck('id'))->orderBy('id')->get(); + $graph = $this->canvas->graph($resources, $links, $canReadSensitive); + + $context = $resources->map(fn (Model $resource) => [ + 'context' => $resource->environment->name, + 'context_link' => route('project.resource.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $resource->environment->uuid]), + ...$this->owner($resource), + ]); + + return [ + 'title' => $project->name, + 'title_link' => route('project.show', ['project_uuid' => $project->uuid]), + 'can_edit' => $canEdit, + // Groups cards by environment inside each server zone, like the server canvas. + 'layout' => 'server', + 'zones' => $graph['zones'], + 'nodes' => collect($graph['nodes'])->map(fn (array $node) => [...$node, ...$context->get($node['id'])])->values()->all(), + 'links' => $graph['links'], + 'positions' => $this->canvas->filterPositions( + $environments->reduce(fn (array $positions, Environment $environment) => [...$positions, ...($environment->canvas_positions ?? [])], []), + $resources->keys()->all(), + ), + ]; + } + + /** + * Project and environment of a card: the canvas saves moves and links on that environment canvas. + * + * @return array{project: array{uuid: string, name: string}, environment: array{uuid: string, name: string}} + */ + private function owner(Model $resource): array + { + return [ + 'project' => ['uuid' => $resource->environment->project->uuid, 'name' => $resource->environment->project->name], + 'environment' => ['uuid' => $resource->environment->uuid, 'name' => $resource->environment->name], + ]; + } +} diff --git a/app/Services/ServerCanvas.php b/app/Services/ServerCanvas.php new file mode 100644 index 0000000000..061bd2ad2c --- /dev/null +++ b/app/Services/ServerCanvas.php @@ -0,0 +1,142 @@ + $servers servers of the team to show + * @return array{title: string, title_link: ?string, can_edit: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array} + */ + public function payload(int $teamId, Collection $servers, ?Server $single, bool $canReadSensitive, bool $canEdit = false): array + { + $environments = Environment::query() + ->whereHas('project', fn ($query) => $query->where('team_id', $teamId)) + ->get(['id', 'canvas_positions']); + $resources = $this->loadResources($environments->pluck('id')); + $links = ResourceLink::query()->whereIn('environment_id', $environments->pluck('id'))->orderBy('id')->get(); + $graph = $this->canvas->graph($resources, $links, $canReadSensitive); + + $serverUuids = $servers->pluck('uuid')->all(); + $context = $resources->map(fn (Model $resource) => [ + 'context' => $resource->environment->project->name.' / '.$resource->environment->name, + 'context_link' => route('project.resource.index', ['project_uuid' => $resource->environment->project->uuid, 'environment_uuid' => $resource->environment->uuid]), + // The canvas filters resources by project and environment. + 'project' => ['uuid' => $resource->environment->project->uuid, 'name' => $resource->environment->project->name], + 'environment' => ['uuid' => $resource->environment->uuid, 'name' => $resource->environment->name], + ]); + $nodes = collect($graph['nodes']) + ->filter(fn (array $node) => array_intersect([$node['zone'], ...$node['extra_zones']], $serverUuids) !== []) + ->map(function (array $node) use ($serverUuids, $context) { + // A resource that also runs on a shown server (additional server) sits in that server's zone. + if (! in_array($node['zone'], $serverUuids, true)) { + $node['zone'] = collect($node['extra_zones'])->first(fn ($uuid) => in_array($uuid, $serverUuids, true)); + } + + return [...$node, ...$context->get($node['id'])]; + }) + ->values(); + $shown = $nodes->pluck('id')->flip(); + + return [ + 'title' => $single ? $single->name : 'Servers', + 'title_link' => $single ? route('server.show', ['server_uuid' => $single->uuid]) : null, + // Unmanaged containers need a command on the server, so the canvas loads them after it renders. + 'containers_endpoint' => $single ? '/servers/'.$single->uuid.'/canvas/containers' : null, + 'can_edit' => $canEdit, + // Tells the canvas to group cards by project / environment and place linked cards side by side. + 'layout' => 'server', + 'zones' => $servers->map(fn (Server $server) => $this->canvas->zone($server, $canReadSensitive))->values()->all(), + 'nodes' => $nodes->all(), + 'links' => collect($graph['links'])->filter(fn (array $link) => $shown->has($link['source']) && $shown->has($link['target']))->values()->all(), + // Card positions saved on the environment canvases; the canvas keeps their arrangement per project / environment group. + 'positions' => $this->canvas->filterPositions( + $environments->reduce(fn (array $positions, Environment $environment) => [...$positions, ...($environment->canvas_positions ?? [])], []), + $nodes->pluck('id')->all(), + ), + ]; + } + + /** + * Read-only cards for the Docker containers on the server that Coolify does not manage. + * Only the one-server canvas loads them, in a separate request, because this runs a command on the server. + * + * @return Collection> + */ + public function unmanagedContainerNodes(Server $server): Collection + { + try { + $containers = $server->loadUnmanagedContainers(); + } catch (\Throwable) { + return collect(); + } + + return $containers + ->filter(fn ($container) => filled(data_get($container, 'ID'))) + ->map(function ($container) use ($server) { + $health = str((string) data_get($container, 'Status'))->match('/\((healthy|unhealthy)\)/')->value(); + + return [ + 'id' => 'container:'.data_get($container, 'ID'), + 'type' => 'container', + 'name' => (string) data_get($container, 'Names'), + 'status' => collect([(string) data_get($container, 'State', 'unknown'), $health])->filter()->implode(':'), + 'zone' => $server->uuid, + 'extra_zones' => [], + 'urls' => [], + 'subtitle' => (string) data_get($container, 'Image'), + 'children' => [], + 'volumes' => [], + 'link' => route('server.resources', ['server_uuid' => $server->uuid]), + 'context' => 'Unmanaged containers', + 'context_link' => route('server.resources', ['server_uuid' => $server->uuid]), + ]; + }) + ->sortBy('name', SORT_NATURAL) + ->values(); + } + + /** + * Top-level resources of the given environments, keyed by uuid, with the relations the canvas reads. + * + * @param Collection $environmentIds + * @return Collection + */ + public function loadResources(Collection $environmentIds): Collection + { + $applications = Application::query()->whereIn('environment_id', $environmentIds) + ->with(['environment.project', 'destination.server.settings', 'additional_servers.settings', 'persistentStorages']) + ->get(); + $services = Service::query()->whereIn('environment_id', $environmentIds) + ->with([ + 'environment.project', + 'server.settings', + 'destination.server.settings', + 'applications.persistentStorages', + 'databases.persistentStorages', + ]) + ->get(); + $databases = collect(EnvironmentCanvas::DATABASE_RELATIONS) + ->flatMap(fn (string $relation) => (new Environment)->{$relation}()->getRelated()->newQuery() + ->whereIn('environment_id', $environmentIds) + ->with(['environment.project', 'destination.server.settings', 'persistentStorages']) + ->get()); + + return collect()->concat($applications)->concat($services)->concat($databases)->keyBy('uuid'); + } +} diff --git a/app/Services/SharedVariableCanvas.php b/app/Services/SharedVariableCanvas.php new file mode 100644 index 0000000000..4e35da400a --- /dev/null +++ b/app/Services/SharedVariableCanvas.php @@ -0,0 +1,297 @@ +>, nodes: array>, links: array>, positions: array{}} + */ + public function payload(string $scope, Model $owner, bool $canEdit): array + { + $variables = $this->variables($owner); + $resources = $this->resources($scope, $owner); + $links = $this->links($scope, $variables, $resources); + + $missing = collect($links) + ->where('missing', true) + ->unique('source') + ->map(fn (array $link) => [ + 'id' => $link['source'], + 'type' => 'variable', + 'name' => $link['key'], + 'subtitle' => 'Deleted shared variable', + 'reference' => $this->reference($scope, $link['key']), + 'missing' => true, + 'zone' => self::SOURCES_ZONE, + 'extra_zones' => [], + 'status' => null, + ]); + + $resourceNodes = $this->resourceNodes($resources); + + return [ + 'title' => $this->title($scope, $owner), + 'title_link' => $this->pageLink($scope, $owner), + 'can_edit' => $canEdit, + 'can_move' => false, + 'layout' => 'connect', + 'scope' => $scope, + 'zones' => [ + ['id' => self::SOURCES_ZONE, 'name' => str($scope)->headline().' variables', 'icon' => 'variable'], + // The canvas moves the resources that use a variable into this zone. + ['id' => self::CONNECTED_ZONE, 'name' => 'Using these variables', 'icon' => 'link'], + ['id' => self::RESOURCES_ZONE, 'name' => 'Other resources', 'icon' => 'service'], + ], + 'nodes' => $variables + ->map(fn (SharedEnvironmentVariable $variable) => [ + 'id' => $this->variableNodeId($variable), + 'type' => 'variable', + 'name' => $variable->key, + 'subtitle' => filled($variable->comment) ? $variable->comment : str($scope)->headline().' variable', + 'reference' => $this->reference($scope, $variable->key), + 'missing' => false, + 'zone' => self::SOURCES_ZONE, + 'extra_zones' => [], + 'status' => null, + ]) + ->concat($missing) + ->concat($resourceNodes) + ->values() + ->all(), + 'links' => $links, + 'positions' => [], + ]; + } + + /** + * Resource cards of a connect canvas (shared variables or tags), with the project / environment + * the canvas groups and filters them by. The canvas moves used ones into the connected zone. + * + * @param Collection $resources keyed by uuid + * @return Collection> + */ + public function resourceNodes(Collection $resources): Collection + { + $graph = $this->canvas->graph($resources, collect(), false); + + return collect($graph['nodes'])->map(function (array $node) use ($resources) { + $environment = $resources->get($node['id'])->environment; + + return [ + ...$node, + 'zone' => self::RESOURCES_ZONE, + 'extra_zones' => [], + 'context' => $environment->project->name.' / '.$environment->name, + 'context_link' => route('project.resource.index', ['project_uuid' => $environment->project->uuid, 'environment_uuid' => $environment->uuid]), + // The canvas filters resources by project and environment. + 'project' => ['uuid' => $environment->project->uuid, 'name' => $environment->project->name], + 'environment' => ['uuid' => $environment->uuid, 'name' => $environment->name], + ]; + }); + } + + /** + * Shared variables of the scope owner, by key. + * + * @return Collection + */ + public function variables(Model $owner): Collection + { + return $owner->environment_variables()->orderBy('key')->get(); + } + + /** + * Top-level resources that can use the variables of the scope, keyed by uuid. + * + * @return Collection + */ + public function resources(string $scope, Model $owner): Collection + { + $environmentIds = match ($scope) { + 'team' => Environment::query()->whereHas('project', fn ($query) => $query->where('team_id', $owner->id))->pluck('id'), + 'project' => $owner->environments()->pluck('id'), + 'environment' => collect([$owner->id]), + 'server' => Environment::query()->whereHas('project', fn ($query) => $query->where('team_id', $owner->team_id))->pluck('id'), + }; + $resources = $this->serverCanvas->loadResources($environmentIds); + + if ($scope === 'server') { + // {{server.KEY}} resolves against the main server of the resource. + $resources = $resources->filter(fn (Model $resource) => $this->mainServer($resource)?->id === $owner->id); + } + + return $resources->sortBy(fn (Model $resource) => mb_strtolower($resource->name))->keyBy('uuid'); + } + + /** + * One link per (shared variable, resource) pair where a variable of the resource references the shared variable. + * + * @param Collection $variables + * @param Collection $resources + * @return array, label: ?string, removable: bool, missing: bool}> + */ + public function links(string $scope, Collection $variables, Collection $resources): array + { + $nodeIdByKey = $variables->mapWithKeys(fn (SharedEnvironmentVariable $variable) => [$variable->key => $this->variableNodeId($variable)]); + $resourceByMorph = $resources->mapWithKeys(fn (Model $resource) => [$resource->getMorphClass().'#'.$resource->getKey() => $resource]); + $links = []; + foreach ($this->resourceVariables($resources) as $environmentVariable) { + $resource = $resourceByMorph->get($environmentVariable->resourceable_type.'#'.$environmentVariable->resourceable_id); + foreach ($this->referencedKeys($scope, $environmentVariable->value) as $key) { + $source = $nodeIdByKey->get($key, 'missing:'.$key); + $id = $source.'|'.$resource->uuid; + $links[$id] ??= ['id' => $id, 'source' => $source, 'target' => $resource->uuid, 'key' => $key, 'keys' => [], 'label' => null, 'removable' => true, 'missing' => ! $nodeIdByKey->has($key)]; + $links[$id]['keys'][] = $environmentVariable->key; + // Only a variable that holds nothing but the reference can be removed from the canvas. + $links[$id]['removable'] = $links[$id]['removable'] && $this->isExactReference($scope, $key, $environmentVariable->value); + } + } + + return collect($links) + ->map(function (array $link) { + $keys = array_values(array_unique($link['keys'])); + + // Label the link when the resource uses another name for the variable. + return [...$link, 'keys' => $keys, 'label' => $keys === [$link['key']] ? null : implode(', ', $keys)]; + }) + ->values() + ->all(); + } + + /** + * Saved variables (not preview copies) of the given resources. + * + * @param Collection $resources + * @return Collection + */ + public function resourceVariables(Collection $resources): Collection + { + if ($resources->isEmpty()) { + return collect(); + } + + return EnvironmentVariable::query() + ->where('is_preview', false) + ->where(function ($query) use ($resources) { + foreach ($resources->groupBy(fn (Model $resource) => $resource->getMorphClass()) as $type => $group) { + $query->orWhere(fn ($query) => $query->where('resourceable_type', $type)->whereIn('resourceable_id', $group->map->getKey()->all())); + } + }) + ->orderBy('key') + ->get(); + } + + /** + * Keys of the shared variables of the scope that the value references. + * + * @return array + */ + public function referencedKeys(string $scope, ?string $value): array + { + if (blank($value)) { + return []; + } + preg_match_all('/{{\s*'.preg_quote($scope, '/').'\.(.+?)\s*}}/', $value, $matches); + + return array_values(array_unique($matches[1])); + } + + public function isExactReference(string $scope, string $key, ?string $value): bool + { + return preg_match('/^{{\s*'.preg_quote($scope, '/').'\.'.preg_quote($key, '/').'\s*}}$/', trim((string) $value)) === 1; + } + + public function reference(string $scope, string $key): string + { + return '{{'.$scope.'.'.$key.'}}'; + } + + public function variableNodeId(SharedEnvironmentVariable $variable): string + { + return 'variable:'.$variable->id; + } + + /** + * The team of the scope owner. + */ + public function teamId(string $scope, Model $owner): int + { + return (int) match ($scope) { + 'team' => $owner->id, + 'project', 'server' => $owner->team_id, + 'environment' => $owner->project->team_id, + }; + } + + public function mainServer(Model $resource): ?Server + { + return $resource instanceof Service ? $resource->server : $resource->destination?->server; + } + + public function title(string $scope, Model $owner): string + { + return match ($scope) { + 'team' => 'Team variables', + 'project' => $owner->name.' variables', + 'environment' => $owner->project->name.' / '.$owner->name.' variables', + 'server' => $owner->name.' variables', + }; + } + + /** + * The canvas of the variable's scope, with the variable selected. + */ + public function connectLink(SharedEnvironmentVariable $variable): ?string + { + $query = '?select='.urlencode($this->variableNodeId($variable)); + + return match ($variable->type) { + 'team' => route('shared-variables.team.canvas').$query, + 'project' => $variable->project ? route('shared-variables.project.canvas', ['project_uuid' => $variable->project->uuid]).$query : null, + 'environment' => $variable->environment?->project + ? route('shared-variables.environment.canvas', ['project_uuid' => $variable->environment->project->uuid, 'environment_uuid' => $variable->environment->uuid]).$query + : null, + 'server' => $variable->server ? route('shared-variables.server.canvas', ['server_uuid' => $variable->server->uuid]).$query : null, + default => null, + }; + } + + public function pageLink(string $scope, Model $owner): string + { + return match ($scope) { + 'team' => route('shared-variables.team.index'), + 'project' => route('shared-variables.project.show', ['project_uuid' => $owner->uuid]), + 'environment' => route('shared-variables.environment.show', ['project_uuid' => $owner->project->uuid, 'environment_uuid' => $owner->uuid]), + 'server' => route('shared-variables.server.show', ['server_uuid' => $owner->uuid]), + }; + } +} diff --git a/app/Services/TagCanvas.php b/app/Services/TagCanvas.php new file mode 100644 index 0000000000..a1d9132d3f --- /dev/null +++ b/app/Services/TagCanvas.php @@ -0,0 +1,120 @@ +>, nodes: array>, links: array>, positions: array{}} + */ + public function payload(int $teamId, bool $canEdit): array + { + $tags = $this->tags($teamId); + $resources = $this->resources($teamId); + + return [ + 'title' => 'Tags', + 'title_link' => route('tags.show'), + 'can_edit' => $canEdit, + 'can_move' => false, + 'layout' => 'connect', + 'zones' => [ + ['id' => SharedVariableCanvas::SOURCES_ZONE, 'name' => 'Tags', 'icon' => 'tag'], + // The canvas moves the tagged resources into this zone. + ['id' => SharedVariableCanvas::CONNECTED_ZONE, 'name' => 'Tagged resources', 'icon' => 'link'], + ['id' => SharedVariableCanvas::RESOURCES_ZONE, 'name' => 'Other resources', 'icon' => 'service'], + ], + 'nodes' => $tags + ->map(fn (Tag $tag) => [ + 'id' => $this->nodeId($tag), + 'type' => 'tag', + 'name' => $tag->name, + 'link' => route('tags.show', ['tagName' => $tag->name]), + 'missing' => false, + 'zone' => SharedVariableCanvas::SOURCES_ZONE, + 'extra_zones' => [], + 'status' => null, + ]) + ->concat($this->connect->resourceNodes($resources)) + ->values() + ->all(), + 'links' => $this->links($tags, $resources), + 'positions' => [], + ]; + } + + /** + * @return Collection + */ + public function tags(int $teamId): Collection + { + return Tag::query()->where('team_id', $teamId)->orderBy('name')->get(); + } + + /** + * Top-level resources of the team with their tags, keyed by uuid. + * + * @return Collection + */ + public function resources(int $teamId): Collection + { + $environmentIds = Environment::query()->whereHas('project', fn ($query) => $query->where('team_id', $teamId))->pluck('id'); + $resources = $this->serverCanvas->loadResources($environmentIds); + $resources->groupBy(fn (Model $resource) => $resource::class) + ->each(fn (Collection $group) => (new EloquentCollection($group->values()->all()))->load('tags')); + + return $resources->sortBy(fn (Model $resource) => mb_strtolower($resource->name))->keyBy('uuid'); + } + + /** + * @param Collection $tags + * @param Collection $resources + * @return array + */ + public function links(Collection $tags, Collection $resources): array + { + $teamTags = $tags->keyBy('id'); + + return $resources + ->flatMap(fn (Model $resource) => $resource->tags + ->filter(fn (Tag $tag) => $teamTags->has($tag->id)) + ->map(fn (Tag $tag) => $this->link($tag, $resource))) + ->values() + ->all(); + } + + /** + * @return array{id: string, source: string, target: string, key: string, keys: array{}, label: null, removable: true, missing: false} + */ + public function link(Tag $tag, Model $resource): array + { + $source = $this->nodeId($tag); + + return ['id' => $source.'|'.$resource->uuid, 'source' => $source, 'target' => $resource->uuid, 'key' => $tag->name, 'keys' => [], 'label' => null, 'removable' => true, 'missing' => false]; + } + + public function nodeId(Tag $tag): string + { + return 'tag:'.$tag->uuid; + } + + /** + * The tags canvas with the tag selected. + */ + public function connectLink(Tag $tag): string + { + return route('tags.canvas').'?select='.urlencode($this->nodeId($tag)); + } +} diff --git a/app/Services/TeamAvatarStorageService.php b/app/Services/TeamAvatarStorageService.php new file mode 100644 index 0000000000..ee66f8bff7 --- /dev/null +++ b/app/Services/TeamAvatarStorageService.php @@ -0,0 +1,76 @@ +avatar_storage_type === 's3' && $settings->avatar_s3_storage_id ? 's3' : 'local'; + $s3StorageId = $storageType === 's3' ? $settings->avatar_s3_storage_id : null; + $disk = $this->disk($storageType, $s3StorageId); + $path = "team-avatars/{$team->id}/avatar.jpg"; + + if (! $disk->put($path, $this->compress($upload))) { + throw new RuntimeException('Unable to store the team avatar.'); + } + + $oldStorageType = $team->avatar_storage_type; + $oldS3StorageId = $team->avatar_s3_storage_id; + $oldPath = $team->avatar_path; + + $team->forceFill([ + 'avatar_path' => $path, + 'avatar_storage_type' => $storageType, + 'avatar_s3_storage_id' => $s3StorageId, + ])->save(); + + if ($oldPath && ($oldStorageType !== $storageType || $oldS3StorageId !== $s3StorageId)) { + $this->disk($oldStorageType ?? 'local', $oldS3StorageId)->delete($oldPath); + } + } + + public function teamContents(Team $team): ?string + { + if (! $team->avatar_path) { + return null; + } + + try { + $disk = $this->disk($team->avatar_storage_type ?? 'local', $team->avatar_s3_storage_id); + } catch (RuntimeException) { + return null; + } + + return $disk->exists($team->avatar_path) ? $disk->get($team->avatar_path) : null; + } + + public function deleteTeam(Team $team): void + { + $this->deleteTeamFile($team); + + $team->forceFill([ + 'avatar_path' => null, + 'avatar_storage_type' => null, + 'avatar_s3_storage_id' => null, + ])->save(); + } + + public function deleteTeamFile(Team $team): void + { + if (! $team->avatar_path) { + return; + } + + try { + $this->disk($team->avatar_storage_type ?? 'local', $team->avatar_s3_storage_id) + ->delete($team->avatar_path); + } catch (RuntimeException) { + } + } +} diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index ca6cd6014d..66154ce7e8 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -26,15 +26,111 @@ function service_logo_urls(mixed $logo): array } use App\Models\Application; +use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\LocalFileVolume; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; use Illuminate\Support\Stringable; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; +/** + * The URL of a logo in public/svgs/resources. The file's modification time is the version, so + * browsers load a changed logo instead of their cached copy. + */ +function resource_logo_url(string $name): string +{ + $path = "svgs/resources/{$name}.svg"; + $modifiedAt = is_file(public_path($path)) ? filemtime(public_path($path)) : false; + + return asset($path).($modifiedAt ? "?v={$modifiedAt}" : ''); +} + +/** + * The logo of a resource, the same one shown on the "New resource" page: the database engine, + * the service template, or how the application is built. Null when there is no specific logo. + * + * Applications with a domain also get the favicon URL of their first domain. The browser loads it + * and shows it in place of the logo when it loads. Coolify itself never requests it. + * + * @return array{src: string, dark: ?string, fallback: ?string, padded: bool, favicon: ?string}|null + */ +function resource_logo(Model $resource): ?array +{ + $resourceLogo = function (string $name, bool $hasDarkVariant = false): array { + return [ + 'src' => resource_logo_url($name), + 'dark' => $hasDarkVariant ? resource_logo_url("{$name}-dark") : null, + 'fallback' => null, + 'padded' => false, + 'favicon' => null, + ]; + }; + + if ($resource instanceof Service) { + $templates = once(fn () => get_service_templates()); + $type = filled($resource->service_type) ? $resource->service_type : str($resource->name)->beforeLast('-')->value(); + $logo = data_get($templates->get(resolve_service_template_key($type, $templates)), 'logo'); + if (! is_string($logo) || $logo === '' || basename($logo) === 'default.webp') { + return null; + } + $urls = service_logo_urls($logo); + + return ['src' => $urls['logo'], 'dark' => null, 'fallback' => $urls['logo_cdn_url'], 'padded' => true, 'favicon' => null]; + } + + if ($resource instanceof Application) { + $logo = match (true) { + $resource->build_pack === 'dockerimage' => $resourceLogo('docker-image'), + $resource->build_pack === 'dockerfile' => $resourceLogo('dockerfile'), + $resource->build_pack === 'dockercompose' => $resourceLogo('docker-compose'), + $resource->source_type === GithubApp::class => $resourceLogo('github-app', true), + $resource->source_type === GitlabApp::class => $resourceLogo('gitlab-app'), + default => $resourceLogo('public-repo'), + }; + + return [...$logo, 'favicon' => resource_favicon_url($resource->fqdn)]; + } + + if (! method_exists($resource, 'type')) { + return null; + } + + return match (str($resource->type())->after('standalone-')->value()) { + 'postgresql' => $resourceLogo('postgres'), + 'mysql' => $resourceLogo('mysql'), + 'mariadb' => $resourceLogo('mariadb'), + 'mongodb' => $resourceLogo('mongodb'), + 'redis' => $resourceLogo('redis'), + 'keydb' => $resourceLogo('keydb', true), + 'dragonfly' => $resourceLogo('dragonfly', true), + 'clickhouse' => $resourceLogo('clickhouse'), + 'sqlite' => $resourceLogo('sqlite'), + default => null, + }; +} + +/** + * The favicon URL of the first domain in a comma-separated domain list, or null when there is no usable domain. + */ +function resource_favicon_url(?string $fqdn): ?string +{ + $domain = str((string) $fqdn)->explode(',')->map(fn (string $url) => trim($url))->first(fn (string $url) => $url !== ''); + $parts = parse_url((string) $domain); + $scheme = strtolower((string) ($parts['scheme'] ?? '')); + $host = (string) ($parts['host'] ?? ''); + if (! in_array($scheme, ['http', 'https'], true) || $host === '' || str_contains($host, '*')) { + return null; + } + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + + return "{$scheme}://{$host}{$port}/favicon.ico"; +} + function replaceRegex(?string $name = null) { return "/\\\${?{$name}[^}]*}?|\\\${$name}\w+/"; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 69d653d668..e824d43ee5 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -892,6 +892,10 @@ function profile_avatar_url(User $user): string } } + if (auth()->check() && $user->id !== auth()->id()) { + return route('team.member.avatar', ['user_id' => $user->id, 'v' => $user->updated_at->timestamp]); + } + return route('profile.avatar', ['v' => $user->updated_at->timestamp]); } @@ -910,6 +914,21 @@ function project_icon_url(Project $project): string ]); } +function team_avatar_url(Team $team): string +{ + if ($team->avatar_storage_type === 's3') { + $url = s3_image_url($team->avatar_s3_storage_id, $team->avatar_path, $team->updated_at->timestamp); + if ($url) { + return $url; + } + } + + return route('team.avatar', [ + 'team_id' => $team->id, + 'v' => $team->updated_at->timestamp, + ]); +} + function s3_image_url(?int $storageId, ?string $path, int $version): ?string { if (! $storageId || blank($path)) { @@ -4601,6 +4620,10 @@ function settingsSearchItems(iterable $groupedItems, array $routeParameters, arr foreach ($groupedItems as $groupLabel => $groupItems) { foreach ($groupItems as $item) { + if ($item['disabled'] ?? false) { + continue; + } + $href = route($item['route'], $routeParameters); $add($item['label'], $groupLabel, $href, $item['navigate'] ?? true); diff --git a/database/migrations/2026_10_12_192444_add_canvas_positions_to_environments_table.php b/database/migrations/2026_10_12_192444_add_canvas_positions_to_environments_table.php new file mode 100644 index 0000000000..aa4a3c3040 --- /dev/null +++ b/database/migrations/2026_10_12_192444_add_canvas_positions_to_environments_table.php @@ -0,0 +1,22 @@ +json('canvas_positions')->nullable(); + }); + } + + public function down(): void + { + Schema::table('environments', function (Blueprint $table) { + $table->dropColumn('canvas_positions'); + }); + } +}; diff --git a/database/migrations/2026_10_12_192445_create_resource_links_table.php b/database/migrations/2026_10_12_192445_create_resource_links_table.php new file mode 100644 index 0000000000..6626982a51 --- /dev/null +++ b/database/migrations/2026_10_12_192445_create_resource_links_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('environment_id')->constrained()->cascadeOnDelete(); + $table->morphs('source'); + $table->morphs('target'); + $table->string('label', 64)->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['environment_id', 'source_type', 'source_id', 'target_type', 'target_id'], 'resource_links_unique_direction'); + }); + } + + public function down(): void + { + Schema::dropIfExists('resource_links'); + } +}; diff --git a/database/migrations/2026_10_13_132049_add_avatar_columns_to_teams_table.php b/database/migrations/2026_10_13_132049_add_avatar_columns_to_teams_table.php new file mode 100644 index 0000000000..2bb341cbb3 --- /dev/null +++ b/database/migrations/2026_10_13_132049_add_avatar_columns_to_teams_table.php @@ -0,0 +1,31 @@ +string('avatar_path')->nullable(); + $table->string('avatar_storage_type')->nullable(); + $table->foreignId('avatar_s3_storage_id')->nullable()->constrained('s3_storages')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->dropConstrainedForeignId('avatar_s3_storage_id'); + $table->dropColumn(['avatar_path', 'avatar_storage_type']); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index f70d8ae9ea..fd2834a7b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,11 @@ "tw-animate-css": "1.4.0" }, "devDependencies": { + "@sveltejs/vite-plugin-svelte": "7.3.1", "@tailwindcss/postcss": "4.3.3", "laravel-vite-plugin": "3.1.3", "postcss": "8.5.26", + "svelte": "5.57.2", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.3.3", "vite": "8.2.1" @@ -42,7 +44,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -53,7 +55,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -64,24 +66,24 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -395,6 +397,46 @@ "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", "license": "MIT" }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/@tailwindcss/forms": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", @@ -712,6 +754,13 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -734,11 +783,44 @@ "addons/*" ] }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "devOptional": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -762,6 +844,16 @@ "node": ">=4" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -772,6 +864,13 @@ "node": ">=8" } }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -786,6 +885,31 @@ "node": ">=10.13.0" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -825,6 +949,16 @@ "dev": true, "license": "ISC" }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1135,11 +1269,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1173,6 +1314,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1333,6 +1488,33 @@ "node": ">=0.10.0" } }, + "node_modules/svelte": { + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tailwind-scrollbar": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.2.tgz", @@ -1798,6 +1980,33 @@ "type": "opencollective", "url": "https://opencollective.com/parcel" } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "devOptional": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 741c243bf4..2541eaf7ce 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,11 @@ "clean": "docker compose -f docker-compose.yml -f docker-compose.dev.yml down --remove-orphans" }, "devDependencies": { + "@sveltejs/vite-plugin-svelte": "7.3.1", "@tailwindcss/postcss": "4.3.3", "laravel-vite-plugin": "3.1.3", "postcss": "8.5.26", + "svelte": "5.57.2", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.3.3", "vite": "8.2.1" diff --git a/public/svgs/resources/docker-compose.svg b/public/svgs/resources/docker-compose.svg index 0101ad46c4..02255ccfc8 100644 --- a/public/svgs/resources/docker-compose.svg +++ b/public/svgs/resources/docker-compose.svg @@ -1,10 +1,5 @@ - + - - - - - - - + + diff --git a/public/svgs/resources/docker-image.svg b/public/svgs/resources/docker-image.svg index 28b300a9cf..3167ffece6 100644 --- a/public/svgs/resources/docker-image.svg +++ b/public/svgs/resources/docker-image.svg @@ -1,11 +1,4 @@ - + - - - - - - - - + diff --git a/public/svgs/resources/dockerfile.svg b/public/svgs/resources/dockerfile.svg index f4a3efb11c..760ed42713 100644 --- a/public/svgs/resources/dockerfile.svg +++ b/public/svgs/resources/dockerfile.svg @@ -1,11 +1,5 @@ - + - - - - - - - + diff --git a/resources/css/app.css b/resources/css/app.css index 57d75d4b4d..3b87dcdb46 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1383,7 +1383,7 @@ html[data-theme="custom"] .coolbox, html[data-theme="custom"] .resource-view, html[data-theme="custom"] [class~="bg-surface"], html[data-theme="custom"] [class~="dark:bg-surface"], -html[data-theme="custom"] [class~="dark:bg-white/[0.025]"] { +html[data-theme="custom"] [class*="dark:bg-white/[0.025]"] { background-color: var(--color-content-surface) !important; } @@ -1856,6 +1856,13 @@ html[data-theme="custom"] textarea:disabled { min-width: 0; } +/* Below xl the rail is a grid above the content; a canvas has no room for it. */ +@media (max-width: 1279.98px) { + .app-main-full .application-settings-navigation { + display: none; + } +} + @media (min-width: 1280px) { /* Fixed like the main sidebar (3rem → bottom); left tracks its width (--sidebar-w). Fixed leaves the grid, so the content sibling is pinned to column 2 below. */ @@ -1903,6 +1910,16 @@ html[data-theme="custom"] textarea:disabled { background: var(--coollabs-line); } + /* Canvas pages with a rail (server canvas): the rail stays above the canvas, + and the canvas starts after the rail. */ + .app-main-full .application-settings-navigation { + z-index: 20; + } + + .app-main-full .application-settings-navigation ~ [data-environment-canvas] { + margin-left: calc(210px + 2.5rem); + } + /* Embeds: keep the surface, drop the fixed positioning. */ .application-settings-navigation.is-flush { position: static; @@ -3135,6 +3152,17 @@ input[type="search"]::-webkit-search-results-decoration { } } +@media (min-width: 1536px) { + .deployment-table-embedded .deployment-table-grid { + grid-template-columns: 6.5rem minmax(0, 1fr) 6.5rem; + } + + .deployment-table-embedded .deployment-table-grid > :nth-child(2), + .deployment-table-embedded .deployment-table-grid > :nth-child(n + 5) { + display: none; + } +} + .dashboard-deployment-table-grid { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(6.25rem, 0.75fr) 10rem 8rem; } @@ -5185,3 +5213,444 @@ a.command-palette-item:focus-visible { height: 2.5rem; } } + +/* Navigation items: each sidebar icon sits in a framed 32px tile with the 3D + depth of .button, like the canvas tool rail. Only the tile reacts; the row + has no background. Applies to both navigation styles. Appearance → + Navigation items → Flat sets data-nav-items="flat" on and turns these + rules off, which brings back the plain rows. */ +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) { + height: 2.5rem; + padding-left: 0.25rem; +} + +/* Only the icon tile reacts: no row background on hover or for the current + item; the current item shows through its tile and its brighter label. + In the utilities layer so it can beat the search row's !bg-* utilities + (a layered !important wins over an unlayered one). */ +@layer utilities { + html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle), + html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover { + background-color: transparent !important; + } + + /* The expanded Search field keeps its input fill (see .menu-search below). */ + html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item { + background-color: var(--color-neutral-100) !important; + } + + html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item:hover { + background-color: var(--color-neutral-200) !important; + } + + html.dark:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item { + background-color: oklch(100% 0 0 / 0.04) !important; + } + + html.dark:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item:hover { + background-color: oklch(100% 0 0 / 0.07) !important; + } + + @media (max-width: 1023.98px) { + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item { + background-color: var(--color-neutral-100) !important; + } + + html.dark:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item { + background-color: oklch(100% 0 0 / 0.04) !important; + } + } +} + +/* The current item's tile takes the accent, like the active tab pill. */ +html:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + color: var(--color-coollabs); + border-color: color-mix(in oklab, var(--color-coollabs) 45%, transparent) !important; + --button-depth-color: color-mix(in oklab, var(--color-coollabs) 45%, transparent) !important; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + color: var(--color-warning); + border-color: color-mix(in oklab, var(--color-warning) 40%, transparent) !important; + --button-depth-color: color-mix(in oklab, var(--color-warning) 35%, transparent) !important; +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):focus-visible { + outline: none; +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):focus-visible .menu-item-icon { + outline: 1px solid var(--color-accent); + outline-offset: 2px; +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon { + box-sizing: content-box; + width: 1rem; + height: 1rem; + padding: 0.4375rem; + border: 1px solid oklch(0% 0 0 / 0.09); + border-radius: 8px; + background: #fafafa; + opacity: 1; + /* Same 3D depth as .button: rests on a 2px ledge, lifts on hover, presses on click. */ + --button-depth-color: rgb(0 0 0 / 0.22); + box-shadow: 0 2px 0 var(--button-depth-color); + transition: border-color 120ms, background-color 120ms, box-shadow 80ms, transform 80ms; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon { + --button-depth-color: rgb(255 255 255 / 0.08); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover .menu-item-icon { + transform: translateY(-1px); + box-shadow: 0 3px 0 var(--button-depth-color); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):active .menu-item-icon { + transform: translateY(2px); + box-shadow: none; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon { + border-color: oklch(100% 0 0 / 0.09); + background: oklch(100% 0 0 / 0.04); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover .menu-item-icon, +html:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + border-color: oklch(0% 0 0 / 0.18); + background: #fff; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover .menu-item-icon, +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + border-color: oklch(100% 0 0 / 0.18); + background: oklch(100% 0 0 / 0.08); +} + +/* Search keeps its original input look while the sidebar is expanded (always on + mobile, where the rail never collapses). Only the collapsed rail shows the 3D tile. + The icon resets use .menu-search.menu-item to beat the html.dark tile rules. */ +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search { + height: 2rem; + padding-left: 0.625rem; +} + +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item .menu-item-icon, +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item:is(:hover, :active, :focus-visible) .menu-item-icon { + box-sizing: border-box; + width: 1rem; + height: 1rem; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + outline: none; + opacity: 0.9; +} + +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search:focus-visible { + outline: 1px solid var(--color-accent); +} + +@media (max-width: 1023.98px) { + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search { + height: 2rem; + padding-left: 0.625rem; + } + + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item .menu-item-icon, + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item:is(:hover, :active, :focus-visible) .menu-item-icon { + box-sizing: border-box; + width: 1rem; + height: 1rem; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + outline: none; + opacity: 0.9; + } +} + +@media (min-width: 1024px) { + /* The collapsed rail is 64px wide with 40px of content: center the 32px tile. */ + html:where(:not([data-nav-items="flat"])) .sidebar-collapsed .menu-item:not(.sidebar-toggle) { + padding-left: 0.25rem; + padding-right: 0.25rem; + } +} + +/* Secondary navigation (settings rails): text-only rows with a filled pill for + the current item, like Sentry. Only the main sidebar keeps the icon tiles. */ +html .application-settings-navigation :is(.menu-item, .menu-subitem) { + height: 2rem; + padding-left: 0.625rem; +} + +html .application-settings-navigation .menu-item-icon { + display: none; +} + +/* In the utilities layer to beat the transparent row rule above. */ +@layer utilities { + html .application-settings-navigation :is(.menu-item, .menu-subitem):hover { + background-color: oklch(0% 0 0 / 0.04) !important; + } + + html.dark .application-settings-navigation :is(.menu-item, .menu-subitem):hover { + background-color: oklch(100% 0 0 / 0.05) !important; + } + + html .application-settings-navigation :is(.menu-item-active, .menu-subitem-active), + html .application-settings-navigation :is(.menu-item-active, .menu-subitem-active):hover { + background-color: oklch(0% 0 0 / 0.06) !important; + } + + html.dark .application-settings-navigation :is(.menu-item-active, .menu-subitem-active), + html.dark .application-settings-navigation :is(.menu-item-active, .menu-subitem-active):hover { + background-color: oklch(100% 0 0 / 0.08) !important; + } +} + +/* Canvases at full zoom: the grid dots turn into small Coolify logos, one + per 20px tile. setCanvasDots() (environment-canvas.js) sets .canvas-zoom-max. */ +.canvas-zoom-max { + --canvas-dots-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath transform='translate(8 8) scale(0.0125)' d='M64 256H0V64h64Zm0-192V0h256v64Zm0 192h256v64H64Z' fill='rgb(0 0 0 / 0.13)'/%3E%3C/svg%3E"); +} + +html.dark .canvas-zoom-max { + --canvas-dots-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath transform='translate(8 8) scale(0.0125)' d='M64 256H0V64h64Zm0-192V0h256v64Zm0 192h256v64H64Z' fill='rgb(255 255 255 / 0.09)'/%3E%3C/svg%3E"); +} + +.canvas-zoom-max :is([data-ec-viewport], .cv-viewport) { + background-image: var(--canvas-dots-image); +} + +/* "Floating" navigation (Appearance → Navigation style): the top bar, sidebar, + tab strip, and settings rail float as framed panels over a dotted backdrop, + like the canvas islands. Set on by applyStoredTheme() from + localStorage.navStyle; the default style keeps edge-to-edge bars. */ +@media (min-width: 1024px) { + html[data-nav-style="floating"] { + --floating-gap: 0.5rem; + --floating-radius: 12px; + --floating-line: oklch(0% 0 0 / 0.09); + /* Bottom edge of the top bar panel plus one gap. */ + --floating-top: calc(3rem + var(--floating-gap) * 2); + } + + html.dark[data-nav-style="floating"] { + --floating-line: oklch(100% 0 0 / 0.08); + --floating-dot: rgb(255 255 255 / 0.09); + } + + /* Dotted backdrop behind the floating panels and in the gaps between them, + like the canvas stage. Fixed so the dots stay still while content scrolls. */ + html[data-nav-style="floating"] .app-shell { + background-image: radial-gradient(circle, var(--floating-dot, rgb(0 0 0 / 0.13)) 1px, transparent 1.3px); + background-size: 20px 20px; + background-attachment: fixed; + } + + html[data-nav-style="floating"] .app-main { + background-color: transparent; + } + + html[data-nav-style="floating"] .app-topbar { + background-color: #fff; + } + + html.dark[data-nav-style="floating"] .app-topbar { + background-color: var(--color-panel); + } + + html[data-nav-style="floating"] :is(.app-topbar, .app-sidebar > div > nav, .app-tabbar) { + border: 1px solid var(--floating-line); + border-radius: var(--floating-radius); + } + + /* The top bar hugs its content (brand, breadcrumb, docked actions) + instead of spanning an empty full-width panel. */ + html[data-nav-style="floating"] .app-topbar { + top: var(--floating-gap); + left: var(--floating-gap); + right: auto; + max-width: calc(100vw - var(--floating-gap) * 2); + } + + /* The bar is opaque here, so it needs no blur. Without the backdrop filter + the bar is no longer the containing block for fixed children, which lets + the resource actions panel below sit against the screen edge. */ + html[data-nav-style="floating"] .app-topbar { + backdrop-filter: none; + } + + /* The panel border replaces the inner dividers of the top bar. */ + html[data-nav-style="floating"] .app-topbar > div { + border-bottom-width: 0; + } + + html[data-nav-style="floating"] .app-sidebar { + top: var(--floating-top); + bottom: var(--floating-gap); + left: var(--floating-gap); + } + + html[data-nav-style="floating"] .app-sidebar > div > nav > .sticky { + border-radius: 0 0 var(--floating-radius) var(--floating-radius); + } + + html[data-nav-style="floating"] .app-main { + margin-left: calc(var(--sidebar-w, 14rem) + var(--floating-gap)); + /* The content top lines up with the sidebar and settings rail panels. */ + padding-top: var(--floating-top); + } + + /* Full-bleed pages (canvases): no top padding gap, and the same gap as the panels. */ + html[data-nav-style="floating"] .app-main.app-main-full { + margin-left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2); + padding: var(--floating-top) var(--floating-gap) var(--floating-gap) 0; + } + + /* Canvases fill the space under the top bar too, and the canvas root + paints the canvas dots (set by the canvas JS) behind the top bar, so + the dots are the same there and on the canvas. */ + html[data-nav-style="floating"] .app-main.app-main-full:has(:is([data-ec-root], .cv-root)) { + padding-top: 0; + } + + /* The resource canvases fill the whole screen behind the floating panels + (top bar, toolbar, sidebar), so the panels do not cut off the cards. */ + html[data-nav-style="floating"] .app-main.app-main-full:has([data-ec-root]) { + margin-left: 0; + padding: 0; + } + + html[data-nav-style="floating"] .app-main-full [data-ec-root] { + padding: 0 !important; + } + + html[data-nav-style="floating"] .app-main-full :is([data-ec-root], .cv-root) { + padding-top: calc(var(--floating-top) + 0.5rem); + background-image: var(--canvas-dots-image, radial-gradient(circle, var(--floating-dot, rgb(0 0 0 / 0.13)) 1px, transparent 1px)); + background-size: var(--canvas-dots-size, 20px 20px); + background-position: var(--canvas-dots-position, 0 0); + } + + /* The deploy canvas Steps panel starts at the same height as the sidebar. */ + html[data-nav-style="floating"] .app-main-full .cv-root { + padding-top: var(--floating-top); + } + + html[data-nav-style="floating"] .app-main-full :is([data-ec-viewport], .cv-viewport) { + background-image: none; + } + + /* Full page width: the content is one gap from the sidebar and the screen edge. */ + html[data-nav-style="floating"] .app-main.app-main-wide { + padding-right: var(--floating-gap); + padding-left: var(--floating-gap); + padding-bottom: var(--floating-gap); + } + + html[data-nav-style="floating"] .app-tabbar { + top: var(--floating-top); + left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2); + right: var(--floating-gap); + } +} + +@media (min-width: 1280px) { + /* Resource actions (Links, Deploy, …) float as their own panel at the top + right. The top bar panel stops one gap before it (the header publishes + --resource-actions-w). */ + html[data-nav-style="floating"] #resource-action-hud-slot:not(:empty) { + position: fixed; + top: var(--floating-gap); + right: var(--floating-gap); + height: 3rem; + padding: 0 0.5rem; + border: 1px solid var(--floating-line); + border-radius: var(--floating-radius); + background-color: #fff; + } + + html.dark[data-nav-style="floating"] #resource-action-hud-slot:not(:empty) { + background-color: var(--color-panel); + } + + html[data-nav-style="floating"] .app-topbar:has(#resource-action-hud-slot:not(:empty)) { + max-width: calc(100vw - var(--floating-gap) * 3 - var(--resource-actions-w, 0px)); + } + + html[data-nav-style="floating"] .application-settings-navigation { + top: var(--floating-top); + left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2); + /* Hug the menu items; long menus scroll inside the viewport. */ + height: auto; + max-height: calc(100dvh - var(--floating-top) - var(--floating-gap)); + border: 1px solid var(--floating-line); + border-radius: var(--floating-radius); + } + + /* Full page width: the content starts one gap after the settings rail. */ + html[data-nav-style="floating"] .app-main-wide :has(> .application-settings-navigation) { + grid-template-columns: calc(210px + 2.5rem) minmax(0, 1fr); + column-gap: var(--floating-gap); + } + + /* The canvas fills the screen behind the rail too; the canvas JS keeps cards clear of it. */ + html[data-nav-style="floating"] .app-main-full .application-settings-navigation ~ [data-environment-canvas] { + margin-left: 0; + } +} + +/* Floating dark mode: the dotted backdrop shows through see-through surfaces + (dark:bg-white/[x] with or without `!`, .button, …). Give the outermost ones the same color as + an opaque mix with the app background, like the canvas cards. Nested + surfaces keep their tint on top, also inside opaque CSS-class panels + (settings sections, boxes, tables, …) that are not app-colored. Generated from the dark:bg-white|black/x + utilities used in resources/views and resources/js; in the utilities layer + with !important so it beats the !bg-* utilities. */ +@layer utilities { + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/10"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/15"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 15%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/20"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 20%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/30"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 30%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/40"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 40%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/55"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 55%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/60"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 60%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/10"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/40"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 40%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.025]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 2.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.02]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 2%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.035]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 3.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.03]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 3%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.045]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 4.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.04]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 4%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.05]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.06]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 6%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.07]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 7%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.08]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 8%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.09]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 9%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.14]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 14%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.1]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main :is(.button, .view-toggle):not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 6%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/10"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.025]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 2.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.035]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 3.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.03]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 3%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.04]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 4%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.05]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.06]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 6%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.07]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 7%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.08]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 8%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.12]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 12%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.1]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main .button:not(:disabled):not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } +} diff --git a/resources/js/app.js b/resources/js/app.js index dde95415a6..af249856ad 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -7,6 +7,26 @@ import { registerLivewireRequestFailureHandler } from './livewire-request-failur import { registerLivewireUpdateDedupe } from './livewire-update-dedupe.js'; import { parseSubmitAction } from './modal-confirmation.js'; +// The canvases (Svelte, Svelte Flow) load only on pages that have one. After the +// import, each canvas mounts itself, also after later Livewire SPA navigation. +const canvases = [ + { selector: '[data-deploy-canvas]', load: () => import('./canvas/deploy/index.js').then((m) => m.initializeDeployCanvas()) }, + { selector: '[data-environment-canvas]', load: () => import('./canvas/environment/index.js').then((m) => m.initializeEnvironmentCanvas()) }, +]; +const loadCanvases = () => { + for (const canvas of canvases) { + if (!canvas.loading && document.querySelector(canvas.selector)) { + canvas.loading = canvas.load(); + } + } +}; +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', loadCanvases); +} else { + loadCanvases(); +} +document.addEventListener('livewire:navigated', loadCanvases); + // Used by the modal-confirmation Blade component to call its submitAction. window.parseModalSubmitAction = parseSubmitAction; @@ -38,6 +58,29 @@ document.addEventListener('alpine:init', initializeSettingsSidebarAccordionCompo * * @param {string|HTMLElement} target Section id or element */ +/** A resource favicon loaded: show it in place of the logo. */ +window.resourceLogoLoaded = function resourceLogoLoaded(img) { + img.closest('[data-resource-logo]')?.querySelector('[data-resource-logo-image]')?.setAttribute('hidden', ''); + img.removeAttribute('hidden'); +}; + +/** A resource logo failed to load: drop a favicon, try the fallback URL of a logo, then show the resource type icon instead. */ +window.resourceLogoError = function resourceLogoError(img) { + if (img.hasAttribute('data-resource-logo-favicon')) { + img.remove(); + return; + } + if (img.dataset.fallback && img.src !== img.dataset.fallback) { + img.src = img.dataset.fallback; + return; + } + const wrapper = img.closest('[data-resource-logo]'); + wrapper?.querySelector('[data-resource-logo-image]')?.setAttribute('hidden', ''); + if (!wrapper?.querySelector('[data-resource-logo-favicon]:not([hidden])')) { + wrapper?.querySelector('[data-resource-logo-icon]')?.removeAttribute('hidden'); + } +}; + window.scrollToSettingsSection = function scrollToSettingsSection(target) { const el = typeof target === 'string' ? document.getElementById(target) : target; if (!el) { diff --git a/resources/js/canvas-core.js b/resources/js/canvas-core.js new file mode 100644 index 0000000000..41d6c26f23 --- /dev/null +++ b/resources/js/canvas-core.js @@ -0,0 +1,246 @@ +// Shared canvas core: API calls, pan/zoom and page mounting for the deploy +// canvas and the environment canvas (Svelte components in resources/js/canvas/). +// +// The top level stays DOM-free: `node --test` imports the canvases. + +export const MIN_ZOOM = 0.25; +export const MAX_ZOOM = 2; + +// ── Collisions ──────────────────────────────────────────────────────── + +/** Space that cards and frames keep between them. */ +export const BOX_GAP = 16; + +/** True when boxes `a` and `b` ({ x, y, w, h }) are at least `gap` apart. */ +export const apart = (a, b, gap = BOX_GAP) => a.x >= b.x + b.w + gap || a.x + a.w + gap <= b.x || a.y >= b.y + b.h + gap || a.y + a.h + gap <= b.y; + +/** + * The free spot nearest to `box` ({ x, y, w, h }) that keeps `gap` clear of + * every box in `others` (and passes `fits`, when given): the box itself, or it + * pushed against an edge (or a corner) of the boxes in the way. Null when no + * such spot is found. + */ +export function freeSpot(box, others, gap = BOX_GAP, fits = () => true) { + const free = b => others.every(o => apart(b, o, gap)) && fits(b); + if (free(box)) return box; + const xs = [box.x, ...others.flatMap(o => [o.x - box.w - gap, o.x + o.w + gap])]; + const ys = [box.y, ...others.flatMap(o => [o.y - box.h - gap, o.y + o.h + gap])]; + let best = null; + let bestDistance = Infinity; + for (const x of xs) { + for (const y of ys) { + const distance = Math.hypot(x - box.x, y - box.y); + if (distance < bestDistance && free({ ...box, x, y })) { + best = { ...box, x, y }; + bestDistance = distance; + } + } + } + return best; +} + +// ── API (Coolify's API controllers at /canvas/api, browser session) ─── + +/** The team of the open canvas page (data-team), fixed when the page was opened. */ +function canvasTeam() { + return document.querySelector('[data-environment-canvas], [data-deploy-canvas]')?.dataset.team || ''; +} + +export class ApiError extends Error { + constructor(status, body) { + const errors = body?.errors ? Object.values(body.errors).flat().map(String) : []; + super([body?.message || `Request failed (HTTP ${status}).`, ...errors.filter(e => e !== body?.message)].join(' ')); + this.status = status; + this.body = body; + } +} + +export async function api(method, path, body, options = {}) { + const response = await fetch(`/canvas/api${path}`, { + method, + credentials: 'same-origin', + keepalive: !!options.keepalive, + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + 'X-Canvas-Team': canvasTeam(), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + let data = null; + try { + data = await response.json(); + } catch { + // Empty body. + } + if (!response.ok) throw new ApiError(response.status, data); + return data; +} + +// ── Pan and zoom ────────────────────────────────────────────────────── + +/** Viewport position inside the root, so the root's dots (floating nav, see app.css) line up with the viewport's. */ +export function canvasDotsOffset(root, viewport) { + const r = root.getBoundingClientRect(); + const v = viewport.getBoundingClientRect(); + return { x: v.left - r.left, y: v.top - r.top }; +} + +export function setCanvasDots(root, size, x, y, atMaxZoom = false) { + root.style.setProperty('--canvas-dots-size', `${size}px ${size}px`); + root.style.setProperty('--canvas-dots-position', `${x}px ${y}px`); + // Fully zoomed in: the grid dots turn into small Coolify logos (app.css). + root.classList.toggle('canvas-zoom-max', atMaxZoom); +} + +/** + * Wheel zoom (ctrl/cmd) and pan, and drag-to-pan on the viewport background. + * getView() returns the current { x, y, z }, which is changed in place. + * canPan(event) decides which presses pan; onPanEnd(moved) runs on release. + */ +export function panZoom({ root, viewport, world, zoomLabel, getView, onChange, canPan, onPanStart, onPanEnd }) { + function apply() { + const { x, y, z } = getView(); + const offset = canvasDotsOffset(root, viewport); + world.style.transform = `translate(${x}px, ${y}px) scale(${z})`; + viewport.style.backgroundSize = `${20 * z}px ${20 * z}px`; + viewport.style.backgroundPosition = `${x}px ${y}px`; + setCanvasDots(root, 20 * z, x + offset.x, y + offset.y, z >= MAX_ZOOM); + zoomLabel.textContent = `${Math.round(z * 100)}%`; + onChange?.(); + } + function toWorld(cx, cy) { + const view = getView(); + const r = viewport.getBoundingClientRect(); + return { x: (cx - r.left - view.x) / view.z, y: (cy - r.top - view.y) / view.z }; + } + /** Zooms to z around a screen point (the viewport centre by default). */ + function zoomTo(z, cx, cy) { + const view = getView(); + const r = viewport.getBoundingClientRect(); + const px = cx ?? r.left + r.width / 2; + const py = cy ?? r.top + r.height / 2; + const before = toWorld(px, py); + view.z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); + view.x = px - r.left - before.x * view.z; + view.y = py - r.top - before.y * view.z; + apply(); + } + + viewport.addEventListener('wheel', e => { + e.preventDefault(); + const view = getView(); + if (e.ctrlKey || e.metaKey) { + zoomTo(view.z * Math.exp(-e.deltaY * 0.01), e.clientX, e.clientY); + } else { + view.x -= e.deltaX; + view.y -= e.deltaY; + apply(); + } + }, { passive: false }); + // Touch: two fingers pinch to zoom and pan, anywhere on the viewport (also on cards). + // Capture phase on the viewport, so the pinch fingers do not also move cards or pan with one finger. + const touches = new Map(); + const pinchFingers = new Set(); + let pinch = null; + const pinchState = () => { + const [a, b] = [...touches.values()]; + return { cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, d: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)) }; + }; + viewport.addEventListener('pointerdown', e => { + if (e.pointerType !== 'touch') return; + touches.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (touches.size !== 2) return; + for (const id of touches.keys()) pinchFingers.add(id); + const start = pinchState(); + pinch = { ...start, z: getView().z, ...toWorld(start.cx, start.cy) }; + onPanStart?.(); + e.stopImmediatePropagation(); + }, true); + viewport.addEventListener('pointermove', e => { + if (!touches.has(e.pointerId)) return; + touches.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (!pinchFingers.has(e.pointerId)) return; + // Pinch fingers stay out of card drags and one-finger pans until they lift. + // Immediate: a one-finger pan listens on the viewport itself (capture listeners run first there). + e.stopImmediatePropagation(); + if (!pinch || touches.size < 2) return; + const now = pinchState(); + const view = getView(); + const r = viewport.getBoundingClientRect(); + view.z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, pinch.z * now.d / pinch.d)); + view.x = now.cx - r.left - pinch.x * view.z; + view.y = now.cy - r.top - pinch.y * view.z; + apply(); + }, true); + // The release still reaches the drag that the first finger started, so that drag ends. + const liftFinger = e => { + touches.delete(e.pointerId); + pinchFingers.delete(e.pointerId); + if (touches.size < 2) pinch = null; + }; + viewport.addEventListener('pointerup', liftFinger, true); + viewport.addEventListener('pointercancel', liftFinger, true); + + viewport.addEventListener('pointerdown', e => { + if (e.button !== 0 || pinch || !canPan(e)) return; + onPanStart?.(); + const view = getView(); + const start = { cx: e.clientX, cy: e.clientY, x: view.x, y: view.y }; + let moved = false; + try { + viewport.setPointerCapture(e.pointerId); + } catch { + // Synthetic or already released pointer. + } + viewport.classList.add('panning'); + const move = ev => { + if (!moved && Math.hypot(ev.clientX - start.cx, ev.clientY - start.cy) < 4) return; + moved = true; + view.x = start.x + ev.clientX - start.cx; + view.y = start.y + ev.clientY - start.cy; + apply(); + }; + const up = () => { + viewport.classList.remove('panning'); + viewport.removeEventListener('pointermove', move); + viewport.removeEventListener('pointerup', up); + viewport.removeEventListener('pointercancel', up); + onPanEnd?.(moved); + }; + viewport.addEventListener('pointermove', move); + viewport.addEventListener('pointerup', up); + viewport.addEventListener('pointercancel', up); + }); + + return { apply, toWorld, zoomTo }; +} + +// ── Page mount (initial load and Livewire SPA navigation) ───────────── + +/** + * Mounts a canvas into the page's container unless it already holds the canvas root. + * mount(container) may return { root, unmount }, unmounted once its root leaves the page. + */ +export function mountOnPage(selector, rootSelector, mount) { + if (typeof document === 'undefined') return; + let active = null; + const run = () => { + if (active && !active.root.isConnected) { + active.unmount(); + active = null; + } + const container = document.querySelector(selector); + if (!container || container.querySelector(rootSelector)) return; + active?.unmount(); + active = mount(container) || null; + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run); + } else { + run(); + } + document.addEventListener('livewire:navigated', run); +} diff --git a/resources/js/canvas/deploy/DeployCanvas.svelte b/resources/js/canvas/deploy/DeployCanvas.svelte new file mode 100644 index 0000000000..72804b6c50 --- /dev/null +++ b/resources/js/canvas/deploy/DeployCanvas.svelte @@ -0,0 +1,165 @@ + + +
+ +
+
+ {#if cv.target} + + + {/if} + Deploy canvas + Beta +
+
+ +
+ + + + +
+ + +
+
+ + {#if noticeOpen} +
+ +

The canvas is not optimized for mobile yet. It works best on a larger screen.{#if cv.target}{' On a phone, use the '}New resource page.{/if}

+ +
+ {/if} + + + +
+
zoomLabel}> +
+ + {#each cv.flow.nodes as n (n.id)} + + {/each} + {#if resultNode} + +
+ + + {#if cv.result.urls.length} + {#each cv.result.urls.slice(0, 3) as url} + {url.replace(/^https?:\/\//, '')} + {/each} + {:else} + Open in Coolify + {/if} + +
+ {/if} +
+
+ + {#if cv.menu} +
+
Add the next step
+ {#each cv.menu.valid as type} + pickNext(type)} /> + {/each} +
+ {/if} + {#if cv.toast} +
{cv.toast.text}
+ {/if} +
+ + {#if cv.dropdown} + {#key cv.dropdown?.trigger} + + {/key} + {/if} +
diff --git a/resources/js/canvas/deploy/Dropdown.svelte b/resources/js/canvas/deploy/Dropdown.svelte new file mode 100644 index 0000000000..8806ded1a5 --- /dev/null +++ b/resources/js/canvas/deploy/Dropdown.svelte @@ -0,0 +1,137 @@ + + +
+ {#if items.length > 8} + (active = 0)} /> + {/if} +
+ {#each shown as [value, text, badge], i} + + {:else} +

{q ? 'No match' : 'None yet'}

+ {/each} +
+ {#if add} +
+ {#if adding} +
+ + { if (e.key !== 'Escape') e.stopPropagation(); }} + /> + +
+

{problem}

+ {:else} + + {/if} +
+ {/if} +
diff --git a/resources/js/canvas/deploy/Edges.svelte b/resources/js/canvas/deploy/Edges.svelte new file mode 100644 index 0000000000..8eac07e5f6 --- /dev/null +++ b/resources/js/canvas/deploy/Edges.svelte @@ -0,0 +1,48 @@ + + + + {#each lines as line} + {#if line.edge} + + + removeEdge(line.edge)}>Click to remove this connection + {/if} + + {/each} + {#if dragged} + + {/if} + diff --git a/resources/js/canvas/deploy/Field.svelte b/resources/js/canvas/deploy/Field.svelte new file mode 100644 index 0000000000..293457c860 --- /dev/null +++ b/resources/js/canvas/deploy/Field.svelte @@ -0,0 +1,105 @@ + + +{#if f.kind === 'note'} +

{f.text}

+{:else} +
+ + + {#if f.kind === 'segmented'} +
+ {#each f.options as [option, text]} + + {/each} +
+ {:else if f.kind === 'select'} + + {#if error}{error}{/if} + {:else if f.kind === 'textarea'} + + {:else} + set(e.currentTarget.value)} + onpointerdown={stop} + onkeydown={stop} + /> + {/if} +
+{/if} diff --git a/resources/js/canvas/deploy/Icon.svelte b/resources/js/canvas/deploy/Icon.svelte new file mode 100644 index 0000000000..f81085b6da --- /dev/null +++ b/resources/js/canvas/deploy/Icon.svelte @@ -0,0 +1,25 @@ + + +{#if glyph} + {@html glyph} +{:else} + +{/if} diff --git a/resources/js/canvas/deploy/Node.svelte b/resources/js/canvas/deploy/Node.svelte new file mode 100644 index 0000000000..b6f36e6965 --- /dev/null +++ b/resources/js/canvas/deploy/Node.svelte @@ -0,0 +1,104 @@ + + +
+ +
dragNode(n, e)}> + + {#if logoSrc && !broken[logoSrc]} + (broken[logoSrc] = true)} /> + {#if logo.dark}{/if} + {:else} + + {/if} + +
{t.title}{t.sub}
+ {#if removable} + + {/if} +
+
+ {#each fields as f (f)} + + {/each} + {#if n.type === 'deploy'} + {#if !plan.lines.length} +

Every resource you connect to a Server shows here.

+ {/if} + {#each plan.lines as { source } (source.id)} + +
+ {/each} + +
+ + +
+ {/if} + {#if t.stage === 'config' && !sourceAbove(n.id, cv.flow)} +

Connect it after a source step.

+ {/if} +
+ {#if n.type !== 'start'}{/if} + {#if st?.message}
{st.message}
{/if} + {#if n.type !== 'deploy'} + + startConnect(n, e)}> + {/if} + {#if blank} +
Drag and drop to add a step
+ {/if} +
diff --git a/resources/js/canvas/deploy/PaletteItem.svelte b/resources/js/canvas/deploy/PaletteItem.svelte new file mode 100644 index 0000000000..f7928cfb04 --- /dev/null +++ b/resources/js/canvas/deploy/PaletteItem.svelte @@ -0,0 +1,21 @@ + + + diff --git a/resources/js/canvas/deploy/RunPanel.svelte b/resources/js/canvas/deploy/RunPanel.svelte new file mode 100644 index 0000000000..77d5fc249e --- /dev/null +++ b/resources/js/canvas/deploy/RunPanel.svelte @@ -0,0 +1,48 @@ + + + diff --git a/resources/js/canvas/deploy/index.js b/resources/js/canvas/deploy/index.js new file mode 100644 index 0000000000..876d4487fd --- /dev/null +++ b/resources/js/canvas/deploy/index.js @@ -0,0 +1,18 @@ +// Deploy canvas (Svelte): mounted on the /canvas page into [data-deploy-canvas], +// on the first load and after Livewire SPA navigation. + +import { mount, unmount } from 'svelte'; +import { mountOnPage } from '../../canvas-core.js'; +import DeployCanvas from './DeployCanvas.svelte'; +import { init } from './state.svelte.js'; +import { injectStyles } from './styles.js'; + +export function initializeDeployCanvas() { + mountOnPage('[data-deploy-canvas]', '.cv-root', container => { + injectStyles(); + init(container); + container.replaceChildren(); + const app = mount(DeployCanvas, { target: container }); + return { root: container.querySelector('.cv-root'), unmount: () => unmount(app) }; + }); +} diff --git a/resources/js/canvas/deploy/state.svelte.js b/resources/js/canvas/deploy/state.svelte.js new file mode 100644 index 0000000000..666955915a --- /dev/null +++ b/resources/js/canvas/deploy/state.svelte.js @@ -0,0 +1,661 @@ +// Deploy canvas runtime: the state of the open canvas, what the steps do to +// it (add, connect, run…), and the Svelte actions around the imperative +// helpers of canvas-core.js (pan/zoom, top bar placement, card sizes). + +import { flushSync, tick } from 'svelte'; +import { api, freeSpot, MIN_ZOOM, panZoom } from '../../canvas-core.js'; +import { + canFollow, defaults, domainList, fresh, LOG_POLL_MS, lists, load, multiOut, nextOf, nodeById, outsOf, planOf, + resourceHref, save, sleep, targetFrom, TYPES, uid, uniqueLabels, withDeploy, withoutSecrets, withTarget, +} from '../../deploy-canvas.js'; + +export const PORT_Y = 26; // header centre (3.25rem / 2) +const CARD_W = 296; + +const emptyRun = () => ({ open: false, title: 'Deploy', sub: '', steps: [], panes: [], active: null, defaultHref: '', openHref: '', showOpen: false }); + +export const cv = $state({ + canAdd: false, // may create projects and environments (from the page) + target: null, // the environment that opened the canvas + flow: fresh(), + view: { x: 40, y: 40, z: 1 }, + runState: {}, // node id -> { state: running|done|failed, message } + invalid: {}, // node id -> true: a required field is empty + // The last successful run's addresses, shown as a pill after Deploy. + result: null, // { after: node id, urls: [], href } + running: false, // false, 'save' or 'deploy' + sizes: {}, // node id -> { w, h } of its card + listsVersion: 0, // bumped when an option list loads + menu: null, // the "Add the next step" menu: { prev, valid, p, left, top } + pendingLine: null, // the dragged line, kept while its menu is open + temp: null, // the line being dragged from a port + armed: null, // node id of the port being dragged + dropTarget: null, // node id the dragged line would connect to + toast: null, // { text, bad } + dropdown: null, // { trigger, items, current, onPick, add } + run: emptyRun(), // the run panel +}); + +// DOM of the open canvas (set by the actions below); mount counts the +// canvases, so a run of an earlier one stops writing here. +export const els = { root: null, stage: null, viewport: null, pz: null, nodeEls: new Map(), mount: 0, fitOnOpen: false }; + +lists.onChange = () => { cv.listsVersion++; }; + +export function init(container) { + els.mount++; + els.nodeEls.clear(); + cv.canAdd = container.dataset.canAdd === '1'; + if (load('deployed', false)) { + save('flow', null); + save('result', null); + save('deployed', false); + } + cv.target = targetFrom(location.search); + cv.flow = withTarget(withDeploy(load('flow', null) || fresh()), cv.target); + save('flow', withoutSecrets(cv.flow)); // drops secrets that older versions stored + // Small screens: the default view is made for desktop, so the first visit fits the steps instead. + els.fitOnOpen = !load('view', null) && !matchMedia('(min-width: 768px)').matches; + cv.view = load('view', { x: 40, y: 40, z: 1 }); + cv.result = load('result', null); + Object.assign(cv, { runState: {}, invalid: {}, running: false, sizes: {}, menu: null, pendingLine: null, temp: null, armed: null, dropTarget: null, toast: null, dropdown: null, run: emptyRun() }); +} + +/** The canvas left the page: its runs stop. */ +export function leave() { + els.mount++; +} + +// ── Actions ──────────────────────────────────────────────────────────── + +/** use:pan on the viewport: wheel and pinch zoom, drag to pan. zoomLabel() is the zoom button. */ +export function pan(viewport, zoomLabel) { + const world = viewport.querySelector('.cv-world'); + const edges = viewport.querySelector('.cv-edges'); + els.viewport = viewport; + els.stage = viewport.parentElement; + els.root = viewport.closest('.cv-root'); + els.pz = panZoom({ + root: els.root, + viewport, + world, + // The button can sit in the app top bar (see dock), so it is looked up on each write. + zoomLabel: { set textContent(text) { const label = zoomLabel(); if (label) label.textContent = text; } }, + getView: () => cv.view, + onChange: closeDropdown, + canPan: e => [viewport, world, edges].includes(e.target), + onPanStart: closeMenu, + }); +} + +const DESKTOP = '(min-width: 1024px)'; +/** + * use:dock on the header. Desktop: it docks into the app top bar, or with the + * floating nav it is a separate panel next to the top bar (as on the + * environment canvas) when it fits there. Small screens: a box above the canvas. + */ +export function dock(top) { + const root = top.closest('.cv-root'); + const stage = root.querySelector('.cv-stage'); + function place() { + const desktop = matchMedia(DESKTOP).matches; + const slot = desktop && document.getElementById('page-toolbar-slot'); + const topbar = document.querySelector('.app-topbar'); + top.removeAttribute('style'); + if (desktop && topbar && document.documentElement.dataset.navStyle === 'floating') { + top.className = 'fixed z-40 flex items-center gap-3 rounded-xl border border-neutral-200 bg-white px-2 py-1.5 dark:border-white/[0.08] dark:bg-panel'; + root.append(top); + const gap = 8; + const head = topbar.getBoundingClientRect(); + const width = top.offsetWidth; + if (head.right + gap + width <= innerWidth - gap) { + // Centered over the canvas, but never over the top bar. + const box = stage.getBoundingClientRect(); + const left = Math.max(box.left + (box.width - width) / 2, head.right + gap); + Object.assign(top.style, { top: `${head.top}px`, left: `${left}px`, minHeight: `${head.height}px` }); + root.classList.add('cv-docked'); + return; + } + top.removeAttribute('style'); + } + top.className = slot ? 'flex items-center gap-3 pl-3' : 'cv-island cv-top'; + root.classList.toggle('cv-docked', !!slot); + if (slot) slot.replaceChildren(top); + else root.prepend(top); + } + place(); + const replace = () => root.isConnected && place(); + // The floating header is centered over the canvas next to the app top bar, so follow their sizes. + const sizes = new ResizeObserver(replace); + for (const target of [stage, document.querySelector('.app-topbar')].filter(Boolean)) sizes.observe(target); + const media = matchMedia(DESKTOP); + media.addEventListener('change', replace); + addEventListener('resize', replace); + const navStyle = new MutationObserver(replace); + navStyle.observe(document.documentElement, { attributes: true, attributeFilter: ['data-nav-style'] }); + return { + destroy() { + sizes.disconnect(); + media.removeEventListener('change', replace); + removeEventListener('resize', replace); + navStyle.disconnect(); + top.remove(); // it may sit in the app top bar + }, + }; +} + +/** use:track on a card: its element (for measuring) and its size (for the lines). */ +export function track(node, id) { + els.nodeEls.set(id, node); + const measure = () => { cv.sizes[id] = { w: node.offsetWidth, h: node.offsetHeight }; }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return { + destroy() { + observer.disconnect(); + if (els.nodeEls.get(id) === node) els.nodeEls.delete(id); + delete cv.sizes[id]; + }, + }; +} + +// ── Canvas: geometry, fit ────────────────────────────────────────────── +// Port positions in world coordinates. +export const outPoint = n => ({ x: n.x + (cv.sizes[n.id]?.w || CARD_W), y: n.y + PORT_Y }); +export const inPoint = n => ({ x: n.x, y: n.y + PORT_Y }); +export const curve = (a, b) => { + const dx = Math.max(40, Math.abs(b.x - a.x) / 2); + return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`; +}; +const toWorld = (cx, cy) => els.pz.toWorld(cx, cy); +const width = n => els.nodeEls.get(n.id)?.offsetWidth || CARD_W; +const height = n => els.nodeEls.get(n.id)?.offsetHeight || 160; +// A card's box in world units (its rendered size, or a usual card size). +const boxOf = n => ({ x: n.x, y: n.y, w: width(n), h: height(n) }); + +export function fit() { + if (!els.nodeEls.size) return; + let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; + for (const n of cv.flow.nodes) { + const node = els.nodeEls.get(n.id); + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + (node?.offsetWidth || 280)); + maxY = Math.max(maxY, n.y + (node?.offsetHeight || 120)); + } + const pill = els.root.querySelector('.cv-result'); + if (pill) { + maxX = Math.max(maxX, pill.offsetLeft + pill.offsetWidth); + maxY = Math.max(maxY, pill.offsetTop + pill.offsetHeight); + } + const r = els.viewport.getBoundingClientRect(); + const pad = 60; + const view = cv.view; + view.z = Math.min(1.2, Math.max(MIN_ZOOM, Math.min((r.width - pad * 2) / (maxX - minX), (r.height - pad * 2) / (maxY - minY)))); + view.x = (r.width - (maxX - minX) * view.z) / 2 - minX * view.z; + view.y = (r.height - (maxY - minY) * view.z) / 2 - minY * view.z; + els.pz.apply(); +} + +// ── Toast, menus, dropdown ───────────────────────────────────────────── +let toastTimer = 0; +export function toast(text, bad = false) { + cv.toast = { text, bad }; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { cv.toast = null; }, bad ? 4200 : 2600); +} + +export function closeMenu() { + cv.menu = null; + cv.pendingLine = null; +} +// Close an older menu but keep the line being dropped. +function openNextMenu(prev, cx, cy, p) { + cv.menu = null; + const valid = Object.keys(TYPES).filter(type => canFollow(prev, type, null, cv.flow)); + if (!valid.length) return toast('Nothing can follow this step.', true); + const r = els.stage.getBoundingClientRect(); + cv.menu = { prev: prev.id, valid, p, left: Math.min(cx - r.left, r.width - 220), top: Math.min(cy - r.top, r.height - 40 * valid.length - 40) }; +} +export function pickNext(type) { + const { prev, p } = cv.menu; + closeMenu(); + const n = addNode(type, p.x + 8, p.y - PORT_Y, true); + connect(prev, n.id); +} + +// Our dropdown panel (fixed in the canvas root, so zoom never shrinks it). +export function closeDropdown() { + cv.dropdown = null; +} +export function openDropdown(trigger, items, current, onPick, add = null) { + if (cv.dropdown?.trigger === trigger) return closeDropdown(); + cv.dropdown = { trigger, items, current, onPick, add }; +} + +// ── Flow edits ───────────────────────────────────────────────────────── +// A structural change redraws the cards, which clears their invalid marks. +const redraw = () => { cv.invalid = {}; }; + +export function connect(from, to) { + const a = nodeById(from, cv.flow); + cv.flow.edges = cv.flow.edges.filter(e => !(e.from === from && e.to === to) && (multiOut(a) || e.from !== from) && e.to !== to); + cv.flow.edges.push({ from, to }); + redraw(); +} +export function removeEdge(edge) { + if (cv.running) return; + cv.flow.edges = cv.flow.edges.filter(e => e !== edge); + redraw(); + toast('Connection removed.'); +} +export function addNode(type, x, y, quiet) { + cv.flow.nodes.push({ id: uid(), type, x: Math.round(x), y: Math.round(y), data: defaults(type) }); + const n = cv.flow.nodes.at(-1); + redraw(); + flushSync(); // render the card, to measure it + settle(n); + if (!quiet) toast(`${TYPES[type].title} added. Connect it from the dot of the step before.`); + return n; +} +export function removeNode(id) { + if (cv.running) return; + cv.flow.nodes = cv.flow.nodes.filter(n => n.id !== id); + cv.flow.edges = cv.flow.edges.filter(e => e.from !== id && e.to !== id); + delete cv.runState[id]; + redraw(); +} +export function resetCanvas() { + if (cv.running) return; + cv.flow = withTarget(fresh(), cv.target); + cv.runState = {}; + cv.result = null; + save('result', null); + save('deployed', false); + redraw(); +} +export function hideResult() { + cv.result = null; + save('result', null); +} +// A field change. Fields loaded from the one changed (`rerender`) start empty again. +export function setField(n, f, value) { + const fields = TYPES[n.type].fields; + n.data[f.key] = value; + f.after?.(n.data); + if (f.rerender) { + for (const other of fields) if (other.load && other !== f && other.key !== f.key && fields.indexOf(other) > fields.indexOf(f)) delete n.data[other.key]; + } + delete cv.invalid[n.id]; +} + +// Move a card to the nearest spot where it does not cover another card. +function settle(n) { + const spot = freeSpot(boxOf(n), cv.flow.nodes.filter(m => m !== n).map(boxOf)); + if (!spot || (spot.x === n.x && spot.y === n.y)) return; + n.x = Math.round(spot.x); + n.y = Math.round(spot.y); +} +// Click on a step in the list: add it after the step it most likely follows. +function appendToChain(type) { + const flow = cv.flow; + const plan = planOf(flow); + // The newest step in the tree this one can follow, with room for it. + const inTree = [plan.start, ...plan.servers, ...plan.lines.flatMap(l => [l.source, ...l.settings])]; + const parent = inTree.reverse().find(n => canFollow(n, type, null, flow) && (multiOut(n) || !nextOf(n.id, flow))); + if (!parent) return addNode(type, ...centre()); + const siblings = multiOut(parent) ? outsOf(parent.id, flow).map(id => nodeById(id, flow)).filter(Boolean) : []; + const y = siblings.length ? Math.max(...siblings.map(n => n.y + height(n))) + 40 : parent.y; + const n = addNode(type, parent.x + width(parent) + 80, y, true); + connect(parent.id, n.id); +} +function centre() { + const r = els.viewport.getBoundingClientRect(); + const p = toWorld(r.left + r.width / 2, r.top + r.height / 2); + return [p.x - 140, p.y - 60]; +} + +// ── Pointer drags ────────────────────────────────────────────────────── + +/** A palette step: drag it to the canvas, or click to append it to the chain. */ +export function dragFromPalette(type, event) { + if (event.button !== 0) return; + const item = event.currentTarget; + const startX = event.clientX; + const startY = event.clientY; + let ghost = null; + const move = e => { + if (!ghost && Math.hypot(e.clientX - startX, e.clientY - startY) < 5) return; + if (!ghost) { + ghost = item.cloneNode(true); + Object.assign(ghost.style, { position: 'fixed', zIndex: 2147483001, width: `${item.offsetWidth}px`, pointerEvents: 'none', opacity: 0.9, background: 'var(--cv-ghost)', borderRadius: '8px', boxShadow: '0 8px 24px -8px rgb(0 0 0 / 0.35)' }); + ghost.style.setProperty('--cv-ghost', 'light-dark(#fff, #222)'); + els.root.append(ghost); + } + ghost.style.left = `${e.clientX - 20}px`; + ghost.style.top = `${e.clientY - 18}px`; + }; + const stop = () => { + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + removeEventListener('pointercancel', cancel); + }; + // Touch: a sideways swipe scrolls the step strip (small screens) and cancels the pointer. + const cancel = () => { + stop(); + ghost?.remove(); + }; + const up = e => { + stop(); + if (!ghost) return appendToChain(type); + ghost.remove(); + const r = els.viewport.getBoundingClientRect(); + if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return; + const p = toWorld(e.clientX, e.clientY); + addNode(type, p.x - 30, p.y - 16); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); + addEventListener('pointercancel', cancel); +} + +/** + * Drag a card by its header. Pointer capture keeps the drag even when the + * pointer outruns the card. + */ +export function dragNode(n, event) { + if (event.button !== 0 || event.target.closest('button, input, select, textarea')) return; + const handle = event.currentTarget; + event.stopPropagation(); + event.preventDefault(); + closeMenu(); + try { handle.setPointerCapture(event.pointerId); } catch { /* not capturable */ } + const ox = event.clientX / cv.view.z - n.x; + const oy = event.clientY / cv.view.z - n.y; + // The other cards stay put during the drag, so measure them once. + const others = cv.flow.nodes.filter(m => m !== n).map(boxOf); + const move = e => { + if (e.pointerId !== event.pointerId) return; + const wanted = { x: e.clientX / cv.view.z - ox, y: e.clientY / cv.view.z - oy, w: width(n), h: height(n) }; + // Cards cannot cover each other: stop at the edge of a card in the way. + const spot = freeSpot(wanted, others); + if (!spot) return; + n.x = Math.round(spot.x); + n.y = Math.round(spot.y); + }; + const up = e => { + if (e.pointerId !== event.pointerId) return; + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + removeEventListener('pointercancel', up); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); + addEventListener('pointercancel', up); +} + +// Drag from an output port: drop on a node to connect, on empty space to pick. +export function startConnect(n, event) { + event.stopPropagation(); + event.preventDefault(); + if (cv.running) return; + cv.armed = n.id; + const a = outPoint(n); + let target = null; + const move = e => { + cv.temp = { a, b: toWorld(e.clientX, e.clientY) }; + const hit = document.elementFromPoint(e.clientX, e.clientY)?.closest?.('.cv-node'); + const id = hit?.dataset.id; + const candidate = id && id !== n.id ? nodeById(id, cv.flow) : null; + target = candidate && canFollow(n, candidate.type, candidate, cv.flow) ? candidate : null; + cv.dropTarget = target?.id ?? null; + }; + const up = e => { + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + cv.armed = null; + cv.temp = null; + cv.dropTarget = null; + if (target) return connect(n.id, target.id); + const over = document.elementFromPoint(e.clientX, e.clientY); + if (over?.closest?.('.cv-node')) return toast('That step cannot follow this one.', true); + const p = toWorld(e.clientX, e.clientY); + if (Math.hypot(p.x - a.x, p.y - a.y) < 12) return; + cv.pendingLine = { a, b: p }; + openNextMenu(n, e.clientX, e.clientY, p); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); +} + +// ── Run ──────────────────────────────────────────────────────────────── +// Log panes: "all" holds the steps; each deployment gets its own tab and pane. +const paneOf = key => cv.run.panes.find(pane => pane.key === key); +function openPane(key, label) { + if (!paneOf(key)) cv.run.panes.push({ key, label, href: '', state: '', status: '', lines: [] }); + return paneOf(key); +} +export function showPane(key) { + const r = cv.run; + r.active = key; + const href = paneOf(key)?.href || r.defaultHref; + if (href) r.openHref = href; +} +function setPaneState(key, state, status) { + const pane = paneOf(key); + if (!pane) return; + pane.state = state; + pane.status = status; +} +function logLine(text, cls = '', key = 'all') { + const pane = paneOf(key) || paneOf('all'); + const box = els.root?.querySelector(`.cv-logs[data-key="${pane.key}"]`); + const atEnd = !box || box.scrollTop + box.clientHeight >= box.scrollHeight - 30; + pane.lines.push({ text, cls }); + if (box && atEnd) tick().then(() => { box.scrollTop = box.scrollHeight; }); +} +function setStep(n, state, message) { + cv.runState[n.id] = { state, message }; + delete cv.invalid[n.id]; + const row = cv.run.steps.find(s => s.id === n.id); + if (row) { + row.state = state; + row.message = message || ''; + } +} + +function validate(plan) { + const problems = []; + if (!plan.servers.length) problems.push('Connect a Server right after Start.'); + else if (!plan.lines.length) problems.push('Connect a source step (Git repository, Docker image, Dockerfile, Service or Database) to a Server.'); + for (const n of [plan.start, ...new Set(plan.lines.map(l => l.server)), ...plan.lines.flatMap(l => [l.source, ...l.settings])]) { + const missing = TYPES[n.type].fields.filter(f => f.required && (!f.show || f.show(n.data)) && !String(n.data[f.key] ?? '').trim()).map(f => f.label); + if (missing.length) { + problems.push(`${TYPES[n.type].title}: fill in ${missing.join(', ')}.`); + cv.invalid[n.id] = true; + } + } + return problems; +} + +export async function runFlow(action = 'deploy') { + if (cv.running) return; + closeMenu(); + const plan = planOf(cv.flow); + const problems = validate(plan); + if (problems.length) { + toast(problems[0], true); + return; + } + cv.running = action; + cv.runState = {}; + cv.result = null; + save('result', null); + save('deployed', false); + const servers = [...new Set(plan.lines.map(l => l.server))]; + const list = [plan.start, ...servers, ...plan.lines.flatMap(l => [l.source, ...l.settings]), plan.deploy]; + const names = uniqueLabels(plan.lines.map(l => l.source)); + const labelOf = n => names.get(n.id) || TYPES[n.type].title; + const r = cv.run; + r.open = true; + r.title = action === 'save' ? 'Saving' : 'Deploying'; + r.sub = `${plan.lines.length} resource${plan.lines.length === 1 ? '' : 's'}`; + r.panes = []; + r.active = null; + r.defaultHref = ''; + openPane('all', 'Steps'); + showPane('all'); + r.showOpen = false; + r.steps = list.map(n => ({ id: n.id, label: labelOf(n), state: '', message: 'Waiting' })); + redraw(); + // The page can be left (and the canvas mounted again) during a run. + const owner = els.mount; + const live = () => els.mount === owner; + // Run one step; false when it failed. + const step = async (n, work) => { + setStep(n, 'running', 'Running…'); + try { + const message = await work(); + if (!live()) return false; + setStep(n, 'done', message); + logLine(`✓ ${labelOf(n)}: ${message}`, 'dim'); + return true; + } catch (error) { + if (!live()) return false; + setStep(n, 'failed', error.message); + logLine(`✗ ${labelOf(n)}: ${error.message}`, 'err'); + return false; + } + }; + const base = {}; + let ok = await step(plan.start, () => TYPES.start.run(base, plan.start.data)); + for (const server of servers) { + if (!ok) break; + ok = await step(server, async () => 'Server picked.'); + } + const shipped = []; // { line, ctx, label } per created resource + for (const line of plan.lines) { + if (!ok) break; + const ctx = { ...base }; + await TYPES.server.run(ctx, line.server.data); + const domainNode = line.settings.find(n => n.type === 'domain'); + if (domainNode) ctx.domains = domainList(domainNode.data.domains); + for (const n of [line.source, ...line.settings]) { + ok = await step(n, () => TYPES[n.type].run(ctx, n.data)); + if (!ok) break; + } + if (ctx.resource) shipped.push({ line, ctx, label: names.get(line.source.id) }); + } + if (!live()) return; + const href = () => shipped.length === 1 ? resourceHref(shipped[0].ctx) : `/project/${base.project}/environment/${base.environment}`; + if (shipped.length) { + r.showOpen = true; + r.openHref = r.defaultHref = href(); + } + if (ok) { + ok = await step(plan.deploy, async () => action === 'save' + ? `Saved ${shipped.length} resource${shipped.length === 1 ? '' : 's'}. Not deployed.` + : deployAll(shipped, live)); + } + if (!live()) return; + if (shipped.length) r.defaultHref = href(); + showPane(r.active); + if (ok) { + const urls = []; + for (const { ctx } of action === 'save' ? [] : shipped) { + let found = String(ctx.domains || ctx.createdDomains || '').split(',').map(v => v.trim()).filter(Boolean); + if (!found.length && ctx.resource.kind === 'application') { + try { + const app = await api('GET', `/applications/${ctx.resource.uuid}`); + found = String(app?.fqdn || '').split(',').map(v => v.trim()).filter(Boolean); + } catch { /* keep none */ } + } + urls.push(...found); + } + cv.result = { after: plan.deploy.id, urls, href: href() }; + save('result', cv.result); + // The next visit starts with an empty canvas. + if (action === 'deploy') save('deployed', true); + } + r.title = ok ? 'Done' : 'Stopped'; + r.sub = ok ? (action === 'save' ? 'Saved in Coolify. Deploy them from there or from here.' : 'Every step finished.') : 'A step failed. Fix it and try again.'; + cv.running = false; +} + +// Deploy every resource at once and follow them. +async function deployAll(shipped, live) { + const many = shipped.length > 1; + // A tab per resource, in order; the first one opens. + for (const { line, ctx, label } of shipped) openPane(line.source.id, label).href = resourceHref(ctx); + if (shipped.length) showPane(shipped[0].line.source.id); + const results = await Promise.allSettled(shipped.map(async ({ line, ctx, label }) => { + const key = line.source.id; + setPaneState(key, 'running', 'Starting'); + try { + const res = await api('POST', '/deploy', { uuid: ctx.resource.uuid }); + if (!live()) return 'Left the canvas.'; + const item = res?.deployments?.[0]; + let message = item?.message || 'Started.'; + if (item?.deployment_uuid) { + ctx.deployment = item.deployment_uuid; + paneOf(key).href = resourceHref(ctx); + if (cv.run.active === key) showPane(key); + message = await followDeployment(label, item.deployment_uuid, key, live); + if (!live()) return message; + } else { + logLine(message, '', key); + } + setPaneState(key, 'done', message); + logLine(`✓ ${label}: ${message}`, 'dim'); + return `${label}: ${message}`; + } catch (error) { + if (live()) { + setPaneState(key, 'failed', error.message.replace(`${label}: `, '')); + logLine(error.message, 'err', key); + } + throw error; + } + })); + const failed = results.filter(res => res.status === 'rejected').map(res => res.reason.message); + if (failed.length) throw new Error(failed.join(' · ')); + return many ? `${results.length} resources deployed.` : results[0].value; +} + +// Poll the deployment (Coolify's own log view also polls every 2 s). +// Logs go to the resource's own pane (key). +async function followDeployment(label, deploymentUuid, key, live) { + logLine(`Deployment ${deploymentUuid}`, 'dim', key); + let shown = 0; + let warned = false; + for (;;) { + if (!live()) return 'Left the canvas.'; + let dep; + try { + dep = await api('GET', `/deployments/${deploymentUuid}`); + } catch (error) { + if (!live()) return 'Left the canvas.'; + logLine(`(could not read the deployment: ${error.message})`, 'err', key); + await sleep(LOG_POLL_MS * 2); + continue; + } + if (!live()) return 'Left the canvas.'; + let entries = []; + if (typeof dep.logs === 'string') { + try { entries = JSON.parse(dep.logs) || []; } catch { entries = []; } + } else if (dep.logs === undefined && !warned) { + warned = true; + logLine('Logs are hidden: only team admins can see deployment logs.', 'err', key); + } + const visible = entries.filter(entry => !entry.hidden); + for (const entry of visible.slice(shown)) { + for (const line of String(entry.output ?? '').split('\n')) if (line.trim()) logLine(line, entry.type === 'stderr' ? 'err' : '', key); + } + shown = visible.length; + setPaneState(key, 'running', String(dep.status || '').replace(/_/g, ' ')); + if (dep.status === 'finished') return 'Deployed.'; + if (dep.status === 'failed') throw new Error(`${label}: deployment failed. See the logs.`); + if (dep.status === 'cancelled-by-user') throw new Error(`${label}: deployment cancelled.`); + await sleep(LOG_POLL_MS); + } +} diff --git a/resources/js/canvas/deploy/styles.js b/resources/js/canvas/deploy/styles.js new file mode 100644 index 0000000000..e04d9951cd --- /dev/null +++ b/resources/js/canvas/deploy/styles.js @@ -0,0 +1,319 @@ +// The deploy canvas look: islands on a backdrop. The colors are CSS variables +// on .cv-root, so a custom color scheme can replace them. Injected once as a +// diff --git a/resources/js/canvas/environment/Icon.svelte b/resources/js/canvas/environment/Icon.svelte new file mode 100644 index 0000000000..8a715697fa --- /dev/null +++ b/resources/js/canvas/environment/Icon.svelte @@ -0,0 +1,10 @@ + + + diff --git a/resources/js/canvas/environment/NodeIcon.svelte b/resources/js/canvas/environment/NodeIcon.svelte new file mode 100644 index 0000000000..920d30b42a --- /dev/null +++ b/resources/js/canvas/environment/NodeIcon.svelte @@ -0,0 +1,37 @@ + + +{#if faviconShown} + +{:else} + {#if !logo?.src || broken} + + {:else} + + + {#if logo.dark} + + {/if} + + {/if} + {#if logo?.favicon && !failed[logo.favicon]} + (loadedFavicon = logo.favicon)} onerror={() => (failed[logo.favicon] = true)} /> + {/if} +{/if} diff --git a/resources/js/canvas/environment/Panel.svelte b/resources/js/canvas/environment/Panel.svelte new file mode 100644 index 0000000000..8086ac4bcd --- /dev/null +++ b/resources/js/canvas/environment/Panel.svelte @@ -0,0 +1,324 @@ + + +{#snippet section(title, content)} +
+

{title}

+ {@render content()} +
+{/snippet} + +{#snippet note(content)} +

{content}

+{/snippet} + +{#snippet shell(title, iconSource, body, footer)} + +{/snippet} + +{#snippet meta(n)} +
+ {TYPE_LABELS[n.type] || n.type} + {statusLabel(n.status)} +
+{/snippet} + +{#snippet openButton(n, cls, label)} + {label} +{/snippet} + +{#snippet removeButton(l)} + {#if canEdit} + + {/if} +{/snippet} + +{#if connectView && node && isSource(node)} + {#snippet body()} +
+ {#if node.reference}{node.reference}{/if} + {#if node.missing}Deleted{/if} +
+ {#if node.missing} + {@render note(`This shared variable was deleted. The resources below still reference it and get the text ${node.reference} instead of a value. Add the variable again, or remove the reference.`)} + {:else if node.subtitle} + {#snippet description()}

{node.subtitle}

{/snippet} + {@render section('Description', description)} + {/if} + {#snippet used()} +
    + {#if !usedBy.length}
  • {text.noTargets}
  • {/if} + {#each usedBy as l (l.id)} + {@const resource = nodeById(l.target)} +
  • + + {@render removeButton(l)} +
  • + {/each} +
+ {/snippet} + {@render section(`${text.usedBy} (${usedBy.length})`, used)} + {#if canEdit && !node.missing} + {@const linked = new Set(usedBy.map(l => l.target))} + !isSource(n) && !linked.has(n.id)), + resource => ({ title: `Add ${node.name} to ${resource.name}`, sub: resource.context || '' }), + resource => oncreate(node.id, resource.id))} /> + {/if} + {#if !node.missing}{@render note(canEdit ? text.note(node) : text.viewOnly)}{/if} + {/snippet} + {#snippet footer()}{@render openButton(node, '', `Open ${TYPE_LABELS[node.type].toLowerCase()}`)}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if connectView && node} + {#snippet body()} + {@render meta(node)} + {#if node.context} + {#snippet context()}{node.context}{/snippet} + {@render section('Project / environment', context)} + {/if} + {#snippet list()} +
    + {#if !sources.length}
  • {text.noSources}
  • {/if} + {#each sources as l (l.id)} + {@const source = nodeById(l.source)} +
  • + + {@render removeButton(l)} +
  • + {/each} +
+ {/snippet} + {@render section(`${text.sources} (${sources.length})`, list)} + {#if canEdit} + {@const linked = new Set(sources.map(l => l.source))} + isSource(n) && !n.missing && !linked.has(n.id)), + source => ({ title: `Add ${source.name} to ${node.name}`, sub: source.type === 'variable' ? source.subtitle || '' : '' }), + source => oncreate(source.id, node.id))} /> + {/if} + {/snippet} + {#snippet footer()}{@render openButton(node, 'button-highlighted', 'Open')}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if connectView && link} + {@const variable = nodeById(link.source)} + {@const resource = nodeById(link.target)} + {#snippet body()} + {#snippet ends()} +
+ {#each [[text.source, variable], ['Resource', resource]] as [endLabel, end] (endLabel)} + + {/each} +
+ {/snippet} + {@render section('Ends', ends)} + {#if link.keys?.length} + {#snippet keys()} +
+ {#each link.keys as key (key)}{key}{/each} +
+ {/snippet} + {@render section(`Variables on ${resource?.name || 'the resource'}`, keys)} + {/if} + {#if link.missing}{@render note(`The shared variable was deleted. ${resource?.name} gets the text ${variable?.reference} instead of a value.`)}{/if} + {#if canEdit && !link.removable}{@render note(removeHint(link))}{/if} + {/snippet} + {#snippet footer()} + + {/snippet} + {@render shell(`${variable?.name || text.source} → ${resource?.name || 'Resource'}`, 'link', body, canEdit ? footer : null)} +{:else if node} + {#snippet body()} + {@render meta(node)} + {#if node.subtitle} + {#snippet details()}

{node.subtitle}

{/snippet} + {@render section('Details', details)} + {/if} + {#if node.urls?.length} + {#snippet urls()} + + {/snippet} + {@render section(node.urls.length === 1 ? 'URL' : 'URLs', urls)} + {/if} + {#snippet serverList()}

{servers.length ? servers.join(', ') : NO_SERVER}

{/snippet} + {@render section(servers.length > 1 ? 'Servers' : 'Server', serverList)} + {#if node.children?.length} + {#snippet containers()} +
    + {#each node.children as child, i (i)} +
  • + + {child.name} + {TYPE_LABELS[child.type] || child.type || ''} +
  • + {/each} +
+ {/snippet} + {@render section('Containers', containers)} + {/if} + {#if node.volumes?.length} + {#snippet volumes()} +
+ {#each node.volumes as v, i (i)}{v}{/each} +
+ {/snippet} + {@render section('Volumes', volumes)} + {/if} + {#snippet linkList()} +
    + {#if !nodeLinks.length}
  • {canEdit ? 'No links. Drag the dot on the right edge of a card to another card.' : 'No links.'}
  • {/if} + {#each nodeLinks as l (l.id)} +
  • + +
  • + {/each} +
+ {/snippet} + {@render section('Links', linkList)} + {/snippet} + {#snippet footer()}{@render openButton(node, 'button-highlighted', 'Open')}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if link} + {#snippet body()} + {#snippet ends()} +
+ {#each [['From', link.source], ['To', link.target]] as [endLabel, id] (endLabel)} + {@const end = nodeById(id)} + + {/each} +
+ {/snippet} + {@render section('Resources', ends)} + {#snippet labelField()} + {#if canEdit} + { + if (e.key === 'Enter') { + e.preventDefault(); + labelInput.blur(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + label = saved; + labelInput.blur(); + } + }} + onblur={saveLabel} + /> + {:else} +

{link.label || 'No label'}

+ {/if} + {/snippet} + {@render section('Label', labelField)} + {@render note('Links are for the diagram only. They do not change your resources.')} + {/snippet} + {#snippet footer()} + + {/snippet} + {@render shell(link.label || 'Link', 'link', body, canEdit ? footer : null)} +{/if} diff --git a/resources/js/canvas/environment/StatusDot.svelte b/resources/js/canvas/environment/StatusDot.svelte new file mode 100644 index 0000000000..26bed75873 --- /dev/null +++ b/resources/js/canvas/environment/StatusDot.svelte @@ -0,0 +1,13 @@ + + + + {#if tone === 'busy'} + + {/if} + + diff --git a/resources/js/canvas/environment/Toolbar.svelte b/resources/js/canvas/environment/Toolbar.svelte new file mode 100644 index 0000000000..f286c791a0 --- /dev/null +++ b/resources/js/canvas/environment/Toolbar.svelte @@ -0,0 +1,51 @@ + + +
+
+ {#if title}{title}{/if} + + Beta + + View only +
+ +
+ + + + +
+ + + +
+ +
+
diff --git a/resources/js/canvas/environment/Zone.svelte b/resources/js/canvas/environment/Zone.svelte new file mode 100644 index 0000000000..2bbc915609 --- /dev/null +++ b/resources/js/canvas/environment/Zone.svelte @@ -0,0 +1,32 @@ + + +
+ +
diff --git a/resources/js/canvas/environment/index.js b/resources/js/canvas/environment/index.js new file mode 100644 index 0000000000..5cb79c380a --- /dev/null +++ b/resources/js/canvas/environment/index.js @@ -0,0 +1,11 @@ +// Environment canvas page mount (initial load and Livewire SPA navigation). +import { mount, unmount } from 'svelte'; +import { mountOnPage } from '../../canvas-core.js'; +import EnvironmentCanvas from './EnvironmentCanvas.svelte'; + +export function initializeEnvironmentCanvas() { + mountOnPage('[data-environment-canvas]', '[data-ec-root]', container => { + const app = mount(EnvironmentCanvas, { target: container, props: { container } }); + return { root: container.querySelector('[data-ec-root]'), unmount: () => unmount(app) }; + }); +} diff --git a/resources/js/deploy-canvas.js b/resources/js/deploy-canvas.js new file mode 100644 index 0000000000..82d28ccd52 --- /dev/null +++ b/resources/js/deploy-canvas.js @@ -0,0 +1,510 @@ +/** + * Deploy canvas: build a deploy workflow (project → server → source → + * settings → deploy) from nodes, run it through Coolify's API controllers + * (at /canvas/api, with the browser session) and follow the deployment logs. + * + * Mounted on the /canvas page into [data-deploy-canvas] by + * canvas/deploy/index.js (Svelte components). This file keeps the DOM-free + * parts: step types, flow rules and storage; `node --test` imports it. + */ + +import { api } from './canvas-core.js'; + +// ── Settings ─────────────────────────────────────────────────────────── +export const LOG_POLL_MS = 2000; // Deployment log refresh (Coolify's own log view uses 2 s too). +export const STORE = 'coolify-canvas'; // localStorage prefix (flow, view). + +// ── Small helpers ────────────────────────────────────────────────────── +// Icons: the app's reicon glyphs (rendered by the page into +// [data-deploy-canvas-icons]), or a few local paths in the same style. +export const LOCAL_ICONS = { + minus: ['M5 12h14'], + fit: ['M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5'], + back: ['M19 12H5', 'M11 6l-6 6 6 6'], // the environment canvas back arrow + fold: ['M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', 'M9 4v16'], // the sidebar toggle in the navbar +}; +export const uid = () => Math.random().toString(36).slice(2, 10); +const b64 = text => btoa(unescape(encodeURIComponent(text))); +export const load = (key, fallback) => { + try { return JSON.parse(localStorage.getItem(`${STORE}:${key}`)) ?? fallback; } catch { return fallback; } +}; +export const save = (key, value) => localStorage.setItem(`${STORE}:${key}`, JSON.stringify(value)); +export const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export const ICONS = { + start: 'projects', + server: 'servers', + git: 'sources', + image: 'cloud', + file: 'file-content', + layers: 'layers', + db: 'database', + sliders: 'sliders', + variables: 'variables', + globe: 'globe', + deploy: 'play-circle', + x: 'x', + plus: 'plus', + minus: 'minus', + fit: 'fit', + out: 'external-link', + fold: 'fold', + check: 'check', + chevron: 'chevron-down', + info: 'info-circle', +}; + +// Cached option lists for the node fields (key → array; loading state). +// onChange runs when a list loads or fails (the canvas redraws its fields). +export const lists = { cache: {}, pending: {}, errors: {}, onChange: () => {} }; +function options(key, fetcher) { + const { cache, pending, errors } = lists; + if (cache[key]) return cache[key]; + if (!pending[key]) { + pending[key] = fetcher().then(list => { + cache[key] = list; + delete errors[key]; + }).catch(error => { + errors[key] = error.message; + }).finally(() => { + delete pending[key]; + lists.onChange(); + }); + } + return null; +} +// The cache key a select loads from (to show its error). +export function loaderKey(f, d) { + const map = { project: 'projects', environment: `envs:${d.project}`, server: 'servers', destination: `dest:${d.server}`, ghApp: 'gh', ghRepo: `repos:${d.ghApp}`, ghBranch: `br:${d.ghApp}:${d.ghRepo}`, key: 'keys' }; + return map[f.key]; +} +const LOADERS = { + projects: () => options('projects', async () => (await api('GET', '/projects')).map(p => [p.uuid, p.name])), + envs: project => project && options(`envs:${project}`, async () => (await api('GET', `/projects/${project}/environments`)).map(e => [e.uuid, e.name])), + // Build-only servers cannot run resources (as on the New resource page). + servers: () => options('servers', async () => (await api('GET', '/servers')).filter(s => s.settings?.server_role !== 'build').map(s => [s.uuid, `${s.name}${s.is_reachable === false ? ' (unreachable)' : ''}`])), + destinations: server => server && options(`dest:${server}`, async () => (await api('GET', `/servers/${server}/destinations`)).map(d => [d.uuid, `${d.name} · ${d.network}`])), + ghApps: () => options('gh', async () => (await api('GET', '/github-apps')).filter(a => !a.is_public && a.app_id).map(a => [`${a.id}|${a.uuid}`, a.name])), + repos: app => app && options(`repos:${app}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories`)).repositories.map(r => [r.full_name, r.full_name])), + branches: (app, repo) => app && repo && options(`br:${app}:${repo}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories/${repo}/branches`)).branches.map(b => [b.name, b.name])), + keys: () => options('keys', async () => (await api('GET', '/security/keys')).map(k => [k.uuid, k.name])), +}; + +// ── Node types ───────────────────────────────────────────────────────── +// stage: start → infra → source → config → ship. kind: what a source creates. +// Select options are [value, label, badge?]; the badge shows next to the label. +const BUILD_PACKS = [['railpack', 'Railpack'], ['nixpacks', 'Nixpacks', 'Deprecated'], ['static', 'Static'], ['dockerfile', 'Dockerfile'], ['dockercompose', 'Docker Compose']]; +// The PostgreSQL images of the New resource page (project/new/select.blade.php). +const POSTGRES_IMAGES = [ + ['postgres:18-alpine', 'PostgreSQL 18'], + ['postgres:17-alpine', 'PostgreSQL 17'], + ['postgres:16-alpine', 'PostgreSQL 16'], + ['supabase/postgres:17.4.1.032', 'Supabase PostgreSQL'], + ['postgis/postgis:17-3.5-alpine', 'PostGIS'], + ['pgvector/pgvector:pg18', 'PGVector 18'], + ['pgvector/pgvector:pg17', 'PGVector 17'], +]; +const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse']]; +// One-click service templates ([key, name]), from the page (deploy-canvas.blade.php). +let serviceTemplates = null; +const templateOptions = () => { + if (!serviceTemplates) { + try { serviceTemplates = JSON.parse(document.querySelector('[data-deploy-canvas-services]')?.textContent || '[]'); } catch { serviceTemplates = []; } + } + return serviceTemplates; +}; + +// The logo of a source step: the one resource_logo() (bootstrap/helpers/services.php) and the +// New resource page show for what it creates. Service logos come from the page +// (deploy-canvas.blade.php). Null keeps the step's glyph. +// The page gives versioned URLs (resource_logo_url()), so a changed logo is not loaded from the cache. +let resourceLogos = null; +const resourceLogoUrl = name => { + if (!resourceLogos) { + try { resourceLogos = JSON.parse(globalThis.document?.querySelector('[data-deploy-canvas-resource-logos]')?.textContent || '{}'); } catch { resourceLogos = {}; } + } + return resourceLogos[name] ?? `/svgs/resources/${name}.svg`; +}; +const resourceLogo = (name, hasDark = false) => ({ + src: resourceLogoUrl(name), dark: hasDark ? resourceLogoUrl(`${name}-dark`) : null, fallback: null, padded: false, +}); +const DATABASE_LOGOS = { postgresql: 'postgres' }; +let serviceLogos = null; +const serviceLogo = key => { + if (!serviceLogos) { + try { serviceLogos = JSON.parse(document.querySelector('[data-deploy-canvas-service-logos]')?.textContent || '{}'); } catch { serviceLogos = {}; } + } + const logo = serviceLogos[key]; + return logo ? { src: logo[0], dark: null, fallback: logo[1], padded: true } : null; +}; +export function logoOf(n) { + const d = n.data || {}; + switch (n.type) { + case 'git': + if (d.buildPack === 'dockerfile') return resourceLogo('dockerfile'); + if (d.buildPack === 'dockercompose') return resourceLogo('docker-compose'); + if (d.mode === 'github') return resourceLogo('github-app', true); + return resourceLogo(d.mode === 'key' ? 'deploy-key' : 'public-repo'); + case 'image': return resourceLogo('docker-image'); + case 'dockerfile': return resourceLogo('dockerfile'); + case 'service': return d.mode === 'compose' ? resourceLogo('docker-compose') : (d.type ? serviceLogo(d.type) : null); + case 'database': return d.engine ? resourceLogo(DATABASE_LOGOS[d.engine] ?? d.engine, ['keydb', 'dragonfly'].includes(d.engine)) : null; + default: return null; + } +} + +export const TYPES = { + start: { + title: 'Start', sub: 'Which project and environment', stage: 'start', icon: ICONS.start, + fields: [ + // `add`: a "New …" row at the end of the list creates one and selects it. + { key: 'project', label: 'Project', kind: 'select', load: () => LOADERS.projects(), rerender: true, required: true, + add: { label: 'New project', placeholder: 'Project name', list: () => 'projects', create: async name => (await api('POST', '/projects', { name })).uuid } }, + { key: 'environment', label: 'Environment', kind: 'select', load: d => LOADERS.envs(d.project), required: true, only: true, + add: { label: 'New environment', placeholder: 'Environment name', list: d => `envs:${d.project}`, create: async (name, d) => (await api('POST', `/projects/${d.project}/environments`, { name })).uuid } }, + ], + run: async (ctx, d) => { + ctx.project = d.project; + ctx.environment = d.environment; + return 'Project and environment set.'; + }, + }, + server: { + title: 'Server', sub: 'Where it runs', stage: 'infra', icon: ICONS.server, + fields: [ + { key: 'server', label: 'Server', kind: 'select', load: () => LOADERS.servers(), rerender: true, required: true, only: true }, + { key: 'destination', label: 'Network', kind: 'select', load: d => LOADERS.destinations(d.server), optional: 'Default' }, + ], + run: async (ctx, d) => { + ctx.server = d.server; + ctx.destination = d.destination || undefined; + return 'Server picked.'; + }, + }, + git: { + title: 'Git repository', sub: 'Build from source', stage: 'source', kind: 'application', icon: ICONS.git, + fields: [ + { key: 'mode', label: 'Access', kind: 'segmented', options: [['public', 'Public'], ['github', 'GitHub app'], ['key', 'Deploy key']], rerender: true, value: 'public' }, + { key: 'repository', label: 'Repository URL', kind: 'text', placeholder: 'https://github.com/coollabsio/coolify-examples', show: d => d.mode !== 'github', required: true, secret: v => /^\w+:\/\/[^/]*@/.test(v) }, + { key: 'ghApp', label: 'GitHub app', kind: 'select', load: () => LOADERS.ghApps(), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'ghRepo', label: 'Repository', kind: 'select', load: d => LOADERS.repos(d.ghApp), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'key', label: 'Deploy key', kind: 'select', load: () => LOADERS.keys(), show: d => d.mode === 'key', required: true }, + { key: 'branch', label: 'Branch', kind: 'text', placeholder: 'main', value: 'main', show: d => d.mode !== 'github', required: true }, + { key: 'ghBranch', label: 'Branch', kind: 'select', load: d => LOADERS.branches(d.ghApp, d.ghRepo), show: d => d.mode === 'github', required: true }, + { key: 'buildPack', label: 'Build pack', kind: 'select', options: BUILD_PACKS, value: 'railpack', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '3000', value: '3000' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const base = common(ctx, d); + Object.assign(base, { build_pack: d.buildPack, ports_exposes: d.ports || undefined }); + let res; + if (d.mode === 'github') { + res = await api('POST', '/applications/private-github-app', { ...base, github_app_uuid: d.ghApp.split('|')[1], git_repository: d.ghRepo, git_branch: d.ghBranch }); + } else if (d.mode === 'key') { + res = await api('POST', '/applications/private-deploy-key', { ...base, private_key_uuid: d.key, git_repository: d.repository, git_branch: d.branch }); + } else { + res = await api('POST', '/applications/public', { ...base, git_repository: d.repository, git_branch: d.branch }); + } + ctx.resource = { kind: 'application', sub: 'git', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + image: { + title: 'Docker image', sub: 'Run a prebuilt image', stage: 'source', kind: 'application', icon: ICONS.image, + fields: [ + { key: 'image', label: 'Image', kind: 'text', placeholder: 'nginx', required: true }, + { key: 'tag', label: 'Tag', kind: 'text', placeholder: 'latest, or the tag in the image' }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const { name, tag } = splitImage(d.image, d.tag); + const res = await api('POST', '/applications/dockerimage', { ...common(ctx, d), docker_registry_image_name: name, docker_registry_image_tag: tag, ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'image', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + dockerfile: { + title: 'Dockerfile', sub: 'Build from a Dockerfile', stage: 'source', kind: 'application', icon: ICONS.file, + fields: [ + { key: 'dockerfile', label: 'Dockerfile', kind: 'textarea', placeholder: 'FROM nginx:alpine\nCOPY . /usr/share/nginx/html', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const res = await api('POST', '/applications/dockerfile', { ...common(ctx, d), dockerfile: b64(d.dockerfile), ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'dockerfile', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + service: { + title: 'Service', sub: 'One-click template or compose', stage: 'source', kind: 'service', icon: ICONS.layers, + fields: [ + { key: 'mode', label: 'From', kind: 'segmented', options: [['template', 'Template'], ['compose', 'Compose']], rerender: true, value: 'template' }, + { key: 'type', label: 'Template', kind: 'select', load: () => templateOptions(), show: d => d.mode !== 'compose', required: true }, + { key: 'compose', label: 'docker-compose.yml', kind: 'textarea', placeholder: 'services:\n app:\n image: nginx', show: d => d.mode === 'compose', required: true, secret: true }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.mode === 'compose') body.docker_compose_raw = b64(d.compose); + else body.type = d.type.trim(); + const res = await api('POST', '/services', body); + ctx.resource = { kind: 'service', uuid: res.uuid }; + return 'Service created.'; + }, + }, + database: { + title: 'Database', sub: 'A managed database', stage: 'source', kind: 'database', icon: ICONS.db, + fields: [ + // A new engine starts with its default image (PostgreSQL: PostgreSQL 18). + { key: 'engine', label: 'Engine', kind: 'select', options: DATABASES, value: 'postgresql', required: true, + after: d => { d.image = d.engine === 'postgresql' ? POSTGRES_IMAGES[0][0] : ''; } }, + { key: 'image', label: 'Image', kind: 'select', options: POSTGRES_IMAGES, value: POSTGRES_IMAGES[0][0], show: d => d.engine === 'postgresql', required: true }, + { key: 'image', label: 'Image', kind: 'text', placeholder: 'Default for the engine', show: d => d.engine !== 'postgresql' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.image) body.image = d.image; + const res = await api('POST', `/databases/${d.engine}`, body); + ctx.resource = { kind: 'database', uuid: res.uuid }; + return 'Database created.'; + }, + }, + build: { + title: 'Build settings', sub: 'Directories and commands', stage: 'config', icon: ICONS.sliders, for: ['git'], + fields: [ + { key: 'base_directory', label: 'Base directory', kind: 'text', placeholder: '/' }, + { key: 'publish_directory', label: 'Publish directory', kind: 'text', placeholder: '/dist' }, + { key: 'install_command', label: 'Install command', kind: 'text', placeholder: 'Detected' }, + { key: 'build_command', label: 'Build command', kind: 'text', placeholder: 'Detected' }, + { key: 'start_command', label: 'Start command', kind: 'text', placeholder: 'Detected' }, + ], + run: async (ctx, d) => { + const body = Object.fromEntries(['base_directory', 'publish_directory', 'install_command', 'build_command', 'start_command'].filter(k => d[k]).map(k => [k, d[k]])); + if (!Object.keys(body).length) return 'Nothing to change.'; + await api('PATCH', `/applications/${ctx.resource.uuid}`, body); + return 'Build settings saved.'; + }, + }, + env: { + title: 'Environment variables', sub: 'KEY=value, one per line', stage: 'config', icon: ICONS.variables, for: ['application', 'service'], + fields: [ + { key: 'vars', label: 'Variables', kind: 'textarea', placeholder: 'NODE_ENV=production\nAPI_URL=https://api.example.com', required: true, mono: true, secret: true }, + ], + run: async (ctx, d) => { + const data = parseEnvVars(d.vars); + if (!data.length) return 'No variables.'; + const kind = ctx.resource.kind === 'service' ? 'services' : 'applications'; + await api('PATCH', `/${kind}/${ctx.resource.uuid}/envs/bulk`, { data }); + return `${data.length} variable${data.length === 1 ? '' : 's'} saved.`; + }, + }, + domain: { + title: 'Domains', sub: 'Your own public addresses', stage: 'config', icon: ICONS.globe, for: ['git', 'image', 'dockerfile'], + fields: [ + { key: 'domains', label: 'Domains', kind: 'textarea', placeholder: 'https://app.example.com', required: true, mono: true, rows: 2 }, + { key: 'note', kind: 'note', text: 'Without this step, Coolify makes an address from the server’s wildcard domain (or sslip.io).' }, + ], + run: async (ctx, d) => { + const domains = domainList(d.domains); + if (ctx.createdDomains === domains) return `Set at creation: ${domains.replace(/,/g, ', ')}.`; + const res = await api('PATCH', `/applications/${ctx.resource.uuid}`, { domains }); + return `Domains saved${res?.uuid ? '' : ''}: ${domains.replace(/,/g, ', ')}.`; + }, + }, + deploy: { + title: 'Deploy', sub: 'Save or deploy every resource', stage: 'ship', icon: ICONS.deploy, + fields: [], + }, +}; +// "nginx:alpine" → nginx + alpine; "ghcr.io:443/a/b@sha256:…" → digest tag +// (Coolify's "sha256-…" form). A tag in the image wins over the Tag field. +export function splitImage(image, tagField) { + let name = String(image || '').trim(); + let tag = ''; + const at = name.indexOf('@sha256:'); + if (at >= 0) { + tag = `sha256-${name.slice(at + 8)}`; + name = name.slice(0, at); + } else { + const colon = name.lastIndexOf(':'); + if (colon > name.lastIndexOf('/')) { + tag = name.slice(colon + 1); + name = name.slice(0, colon); + } + } + return { name, tag: tag || String(tagField || '').trim() || 'latest' }; +} +// Fields common to every create call. Domains from a Domains step later in +// the chain go in at creation (with no generated domain), as Coolify's +// create endpoints accept them. +function common(ctx, d) { + const body = { project_uuid: ctx.project, environment_uuid: ctx.environment, server_uuid: ctx.server, instant_deploy: false }; + if (ctx.destination) body.destination_uuid = ctx.destination; + if (d.name) body.name = d.name; + if (ctx.domains && ctx.applicationSource) { + body.domains = ctx.domains; + body.autogenerate_domain = false; + } + return body; +} +// "KEY=value" lines → [{ key, value }]; skips blanks and # comments, strips quotes. +export function parseEnvVars(text) { + return String(text || '').split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#') && line.includes('=')).map(line => { + const i = line.indexOf('='); + return { key: line.slice(0, i).trim(), value: line.slice(i + 1).trim().replace(/^(['"])(.*)\1$/, '$2') }; + }); +} +export const domainList = text => String(text || '').split(/[\n,]/).map(v => v.trim()).filter(Boolean).join(','); +export const PALETTE = [ + ['Infrastructure', ['server']], + ['Source', ['git', 'image', 'dockerfile', 'service', 'database']], + ['Configure', ['build', 'env', 'domain']], +]; + +// ── Flow state ───────────────────────────────────────────────────────── +// Every deploy needs a server, so a new canvas has one, already connected +// (id 'server': it cannot be removed, nor its connection to Start). +export const fresh = () => withDeploy({ + nodes: [{ id: 'start', type: 'start', x: 80, y: 160, data: {} }, { id: 'server', type: 'server', x: 456, y: 160, data: {} }], + edges: [{ from: 'start', to: 'server' }], +}); +// A select with `only: true` takes its option when it has exactly one. +export const onlyOption = (list, value) => (!value && list?.length === 1 ? list[0][0] : value); +// Every flow ends in one Deploy step. Resource lines reach it on their own, so +// edges into it (from older saved flows) are dropped. +export function withDeploy(f) { + if (!f.nodes.some(n => n.type === 'deploy')) f.nodes.push({ id: 'deploy', type: 'deploy', x: 1240, y: 160, data: {} }); + const deployIds = new Set(f.nodes.filter(n => n.type === 'deploy').map(n => n.id)); + f.edges = f.edges.filter(e => !deployIds.has(e.to) && !deployIds.has(e.from)); + return f; +} +// The environment that opened the canvas (?project=…&environment=…, from the +// New resource page), or null. Both values must look like UUIDs. +export function targetFrom(search) { + const params = new URLSearchParams(search); + const project = params.get('project'); + const environment = params.get('environment'); + const valid = value => /^[A-Za-z0-9_-]+$/.test(value ?? ''); + return valid(project) && valid(environment) ? { project, environment } : null; +} +// The Start step targets that environment, also in a flow restored from an earlier visit. +export function withTarget(f, t) { + const start = f.nodes.find(n => n.type === 'start'); + if (t && start) start.data = { project: t.project, environment: t.environment }; + return f; +} +// Secret fields (`secret: true`, or a test on the value) stay in memory only; +// they are never written to localStorage. +export function withoutSecrets(f) { + const nodes = f.nodes.map(n => { + const data = { ...n.data }; + for (const { key, secret } of TYPES[n.type]?.fields || []) { + if (secret === true || (typeof secret === 'function' && data[key] && secret(data[key]))) delete data[key]; + } + return { ...n, data }; + }); + return { ...f, nodes }; +} +// The flow is a tree: Start → Servers → one line per resource (source → +// settings…), and every line ends in the one Deploy step. +export const nodeById = (id, f) => f.nodes.find(n => n.id === id); +export const outsOf = (id, f) => f.edges.filter(e => e.from === id).map(e => e.to); +export const nextOf = (id, f) => outsOf(id, f)[0]; +export const prevOf = (id, f) => f.edges.find(e => e.to === id)?.from; +// Start and Server fan out; only Deploy takes more than one line in. +export const multiOut = n => ['start', 'infra'].includes(TYPES[n.type].stage); +// The source node above a node in its line (what the settings apply to). +export function sourceAbove(id, f) { + const seen = new Set(); + for (let cur = id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (n && TYPES[n.type].stage === 'source') return n; + } + return null; +} +// Can a `type` step come after `prev`? With `target`, also: the link makes no loop. +export function canFollow(prev, type, target, f) { + const t = TYPES[type]; + const p = TYPES[prev.type]; + if (type === 'start' || type === 'deploy' || prev.type === 'deploy') return false; + if (target) { + const seen = new Set(); + for (let cur = prev.id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + if (cur === target.id) return false; + seen.add(cur); + } + } + if (p.stage === 'start') return type === 'server'; + if (p.stage === 'infra') return t.stage === 'source'; + const src = sourceAbove(prev.id, f); + if (!src) return false; + if (t.stage === 'ship') return true; + if (t.stage !== 'config') return false; + const sub = src.type === 'git' || src.type === 'image' || src.type === 'dockerfile' ? src.type : null; + return t.for.includes(TYPES[src.type].kind) || (sub !== null && t.for.includes(sub)); +} +// What a run does: each resource line under each Server under Start. +export function planOf(f) { + const servers = outsOf('start', f).map(id => nodeById(id, f)).filter(n => n?.type === 'server'); + const lines = []; + for (const server of servers) { + for (const id of outsOf(server.id, f)) { + const source = nodeById(id, f); + if (!source || TYPES[source.type].stage !== 'source') continue; + const settings = []; + const seen = new Set([source.id]); + for (let cur = nextOf(source.id, f); cur && !seen.has(cur); cur = nextOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (!n) break; + settings.push(n); + } + lines.push({ server, source, settings, end: settings.at(-1) || source }); + } + } + return { start: nodeById('start', f), servers, lines, deploy: f.nodes.find(n => n.type === 'deploy') || null }; +} +// Source id → label; same labels get a number ("Docker image · nginx #2"). +export function uniqueLabels(sources) { + const labels = sources.map(resourceLabel); + const seen = {}; + return new Map(sources.map((src, i) => { + const label = labels[i]; + if (labels.filter(l => l === label).length < 2) return [src.id, label]; + seen[label] = (seen[label] || 0) + 1; + return [src.id, `${label} #${seen[label]}`]; + })); +} +// "Docker image · nginx": what a source step makes, for lists and logs. +export function resourceLabel(src) { + const d = src.data; + const detail = d.name || { + git: d.mode === 'github' ? d.ghRepo : d.repository, + image: d.image, + service: d.mode === 'compose' ? 'compose' : d.type, + database: d.engine, + }[src.type]; + return detail ? `${TYPES[src.type].title} · ${detail}` : TYPES[src.type].title; +} +export function defaults(type) { + const data = {}; + for (const f of TYPES[type].fields) if (f.value !== undefined) data[f.key] = f.value; + return data; +} +// The Coolify page of a created resource (its deployment, while one runs). +export function resourceHref(ctx) { + const base = `/project/${ctx.project}/environment/${ctx.environment}`; + const kind = { application: 'application', service: 'service', database: 'database' }[ctx.resource.kind]; + return ctx.deployment && kind === 'application' ? `${base}/application/${ctx.resource.uuid}/deployment/${ctx.deployment}` : `${base}/${kind}/${ctx.resource.uuid}`; +} diff --git a/resources/js/deploy-canvas.test.js b/resources/js/deploy-canvas.test.js new file mode 100644 index 0000000000..e4f9be5461 --- /dev/null +++ b/resources/js/deploy-canvas.test.js @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { freeSpot } from './canvas-core.js'; +import { canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; + +test('splits the tag out of an image name', () => { + assert.deepEqual(splitImage('nginx:alpine', 'latest'), { name: 'nginx', tag: 'alpine' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', ''), { name: 'ghcr.io:443/org/app', tag: 'latest' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', 'v2'), { name: 'ghcr.io:443/org/app', tag: 'v2' }); +}); + +test('turns an image digest into the sha256- tag form', () => { + assert.deepEqual(splitImage('redis@sha256:abc123', 'latest'), { name: 'redis', tag: 'sha256-abc123' }); +}); + +test('parses KEY=value lines and skips comments and blanks', () => { + assert.deepEqual(parseEnvVars('# comment\n\nNODE_ENV=production\nURL="https://a.b/?x=1"\nNOPE\n'), [ + { key: 'NODE_ENV', value: 'production' }, + { key: 'URL', value: 'https://a.b/?x=1' }, + ]); +}); + +test('joins domains from lines and commas', () => { + assert.equal(domainList(' https://a.com\nhttps://b.com, https://c.com \n'), 'https://a.com,https://b.com,https://c.com'); +}); + +// Start → Server → (Service, Dockerfile → Env), and the one Deploy step. +const node = (id, type, data = {}) => ({ id, type, x: 0, y: 0, data }); +const fanOut = () => ({ + nodes: [ + node('start', 'start'), + node('srv', 'server'), + node('svc', 'service', { mode: 'template', type: 'umami' }), + node('df', 'dockerfile'), + node('env', 'env'), + node('dep', 'deploy'), + ], + edges: [ + { from: 'start', to: 'srv' }, + { from: 'srv', to: 'svc' }, + { from: 'srv', to: 'df' }, + { from: 'df', to: 'env' }, + ], +}); + +test('plans one line per resource under a server, each ending at its last step', () => { + const plan = planOf(fanOut()); + assert.equal(plan.deploy.id, 'dep'); + assert.deepEqual(plan.lines.map((l) => [l.server.id, l.source.id, l.settings.map((n) => n.id), l.end.id]), [ + ['srv', 'svc', [], 'svc'], + ['srv', 'df', ['env'], 'env'], + ]); +}); + +test('adds the deploy step to a flow without one and drops edges into it', () => { + const flow = withDeploy({ + nodes: [node('start', 'start'), node('srv', 'server'), node('img', 'image'), node('old', 'deploy')], + edges: [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }, { from: 'img', to: 'old' }], + }); + assert.deepEqual(flow.edges, [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }]); + assert.equal(flow.nodes.filter((n) => n.type === 'deploy').length, 1); + + const empty = withDeploy({ nodes: [node('start', 'start')], edges: [] }); + assert.equal(empty.nodes.filter((n) => n.type === 'deploy').length, 1); +}); + +test('allows only steps that fit after a step', () => { + const flow = fanOut(); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('srv'), 'image', null, flow), true); + assert.equal(canFollow(byId('start'), 'image', null, flow), false); + assert.equal(canFollow(byId('env'), 'deploy', byId('dep'), flow), false); // Deploy is connected on its own + assert.equal(canFollow(byId('svc'), 'build', null, flow), false); // build settings are for git only + assert.equal(canFollow(byId('dep'), 'env', null, flow), false); +}); + +test('refuses a link that would make a loop', () => { + const flow = fanOut(); + flow.nodes.push(node('env2', 'env')); + flow.edges.push({ from: 'env', to: 'env2' }); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('env2'), 'env', byId('env'), flow), false); +}); + +test('numbers resources that would have the same name', () => { + const names = uniqueLabels([ + node('a', 'image', { image: 'nginx' }), + node('b', 'image', { image: 'nginx' }), + node('c', 'service', { mode: 'template', type: 'umami' }), + ]); + assert.deepEqual([...names.values()], ['Docker image · nginx #1', 'Docker image · nginx #2', 'Service · umami']); +}); + +test('keeps secrets out of the stored flow', () => { + const flow = { + nodes: [ + node('env', 'env', { vars: 'TOKEN=abc' }), + node('svc', 'service', { mode: 'compose', compose: 'services: {}', name: 'web' }), + node('git', 'git', { repository: 'https://user:tok@github.com/a/b', branch: 'main' }), + node('pub', 'git', { repository: 'https://github.com/a/b' }), + ], + edges: [], + }; + const stored = withoutSecrets(flow); + assert.deepEqual(stored.nodes.map((n) => n.data), [{}, { mode: 'compose', name: 'web' }, { branch: 'main' }, { repository: 'https://github.com/a/b' }]); + assert.equal(flow.nodes[0].data.vars, 'TOKEN=abc'); // the open canvas keeps them +}); + +test('reads the environment that opened the canvas from the query string', () => { + assert.deepEqual(targetFrom('?project=p1a2&environment=e_3-x'), { project: 'p1a2', environment: 'e_3-x' }); + assert.equal(targetFrom(''), null); + assert.equal(targetFrom('?project=p1a2'), null); + assert.equal(targetFrom('?project=../servers&environment=e1'), null); + assert.equal(targetFrom('?project=p1&environment='), null); +}); + +test('points the Start step of a restored flow at the opening environment', () => { + const flow = { + nodes: [node('start', 'start', { project: 'old', environment: 'old-env' }), node('srv', 'server', { server: 's1' })], + edges: [{ from: 'start', to: 'srv' }], + }; + withTarget(flow, { project: 'p1', environment: 'e1' }); + assert.deepEqual(flow.nodes.map((n) => n.data), [{ project: 'p1', environment: 'e1' }, { server: 's1' }]); + assert.deepEqual(withTarget(flow, null).nodes[0].data, { project: 'p1', environment: 'e1' }); +}); + +test('a new canvas starts with a Server step connected to Start', () => { + const plan = planOf(fresh()); + assert.deepEqual(plan.servers.map((n) => n.id), ['server']); + assert.equal(plan.deploy?.type, 'deploy'); + assert.deepEqual(plan.lines, []); +}); + +test('selects the only option, and only when nothing is selected', () => { + assert.equal(onlyOption([['srv-1', 'localhost']], ''), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], undefined), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], 'srv-old'), 'srv-old'); + assert.equal(onlyOption([['srv-1', 'a'], ['srv-2', 'b']], ''), ''); + assert.equal(onlyOption([], ''), ''); + assert.equal(onlyOption(null, ''), ''); // still loading +}); + +test('keeps a dragged card off the other cards', () => { + const other = { x: 0, y: 0, w: 100, h: 100 }; + const free = { x: 300, y: 0, w: 100, h: 100 }; + assert.deepEqual(freeSpot(free, [other]), free); + // Overlaps from the right: pushed to the right edge, plus the gap. + assert.deepEqual(freeSpot({ x: 90, y: 10, w: 100, h: 100 }, [other], 16), { x: 116, y: 10, w: 100, h: 100 }); + // Overlaps from below: pushed under it. + assert.deepEqual(freeSpot({ x: 5, y: 80, w: 100, h: 100 }, [other], 16), { x: 5, y: 116, w: 100, h: 100 }); +}); + +test('finds a corner spot when two cards block the way', () => { + const cards = [{ x: 0, y: 0, w: 100, h: 100 }, { x: 116, y: 0, w: 100, h: 100 }]; + const spot = freeSpot({ x: 60, y: 20, w: 100, h: 100 }, cards, 16); + for (const c of cards) { + const apart = spot.x >= c.x + c.w + 16 || spot.x + spot.w + 16 <= c.x || spot.y >= c.y + c.h + 16 || spot.y + spot.h + 16 <= c.y; + assert.ok(apart, 'the spot does not touch a card'); + } + assert.deepEqual(spot, { x: 60, y: 116, w: 100, h: 100 }); +}); + +test('shows the logo of what a source step creates, like the rest of Coolify', () => { + const src = (type, data) => logoOf(node('n', type, data))?.src ?? null; + assert.equal(src('image', {}), '/svgs/resources/docker-image.svg'); + assert.equal(src('dockerfile', {}), '/svgs/resources/dockerfile.svg'); + assert.equal(src('database', { engine: 'postgresql' }), '/svgs/resources/postgres.svg'); + assert.equal(src('database', { engine: 'mysql' }), '/svgs/resources/mysql.svg'); + assert.equal(logoOf(node('n', 'database', { engine: 'keydb' })).dark, '/svgs/resources/keydb-dark.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'railpack' }), '/svgs/resources/public-repo.svg'); + assert.equal(src('git', { mode: 'github', buildPack: 'railpack' }), '/svgs/resources/github-app.svg'); + assert.equal(src('git', { mode: 'key', buildPack: 'railpack' }), '/svgs/resources/deploy-key.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'dockerfile' }), '/svgs/resources/dockerfile.svg'); + assert.equal(src('service', { mode: 'compose' }), '/svgs/resources/docker-compose.svg'); + // No engine or template yet, or a step that creates nothing: the step keeps its glyph. + assert.equal(src('database', {}), null); + assert.equal(src('server', {}), null); +}); diff --git a/resources/js/environment-canvas.js b/resources/js/environment-canvas.js new file mode 100644 index 0000000000..ed2f105912 --- /dev/null +++ b/resources/js/environment-canvas.js @@ -0,0 +1,621 @@ +// Environment canvas (Beta): pure helpers and constants. The Svelte +// components live in canvas/environment/. +// +// DOM-free: `node --test` imports this file. + +import { apart, BOX_GAP, MAX_ZOOM, MIN_ZOOM } from './canvas-core.js'; + +export const CARD_WIDTH = 260; +export const COLUMN_GAP = 48; +export const ROW_GAP = 24; +export const ZONE_GAP = 72; +export const ZONE_PADDING = 24; +export const ZONE_HEADER = 40; +export const MAX_CHILDREN = 5; +export const MAX_VOLUMES = 3; +export const MAX_ROWS = 6; // auto placement wraps a type into another column after this many cards +export const NO_SERVER = 'No server'; +export const DOUBLE_CLICK_MS = 400; +export const SOURCES_ZONE = 'sources'; +export const CONNECTED_ZONE = 'connected'; + +const COLUMNS = { application: 0, service: 1, database: 2 }; +/** Connect view: shared variable and tag cards are link sources; every other card is a resource. */ +export const isSource = node => node?.type === 'variable' || node?.type === 'tag'; +const columnOf = node => COLUMNS[node?.type] ?? 0; +const columnLeft = (originX, column) => originX + column * (CARD_WIDTH + COLUMN_GAP); +const isPoint = p => !!p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y)); +const storedPositions = graph => (graph?.positions && !Array.isArray(graph.positions) ? graph.positions : {}); + +// ── Pure helpers (tested) ───────────────────────────────────────────── + +/** Estimated card height in px; matches the rendered card closely enough for auto placement. */ +export function estimateNodeHeight(node) { + let height = 58; // padding + icon/name row + if (node?.urls?.length) height += 20; + if (node?.extra_zones?.length || node?.volumes?.length) height += 28; + const children = node?.children?.length || 0; + if (children) height += 12 + Math.min(children, MAX_CHILDREN) * 20 + (children > MAX_CHILDREN ? 20 : 0); + return height; +} + +/** Zones in display order (graph.zones order, then "No server"), each with its nodes. Empty zones are skipped. */ +export function zoneGroups(graph) { + const zones = graph?.zones || []; + const known = new Set(zones.map(z => z.id)); + const groups = zones.map(zone => ({ id: zone.id, name: zone.name, zone, nodes: [] })); + const none = { id: null, name: NO_SERVER, zone: null, nodes: [] }; + for (const node of graph?.nodes || []) { + if (node.zone !== null && node.zone !== undefined && known.has(node.zone)) { + groups.find(g => g.id === node.zone).nodes.push(node); + } else { + none.nodes.push(node); + } + } + return [...groups, none].filter(g => g.nodes.length); +} + +/** + * Card positions (top-left, world px) for every node. Stored positions win; + * other nodes are placed in their zone: applications left, services middle, + * databases right. Zones without stored cards stack below everything else. + */ +export function layoutGraph(graph) { + if (graph?.layout === 'server') return layoutServerView(graph); + if (graph?.layout === 'connect') return layoutConnectView(graph); + const stored = storedPositions(graph); + const out = {}; + const placedBottom = []; + const fresh = []; + for (const group of zoneGroups(graph)) { + const fixed = group.nodes.filter(n => isPoint(stored[n.id])); + if (!fixed.length) { + fresh.push(group); + continue; + } + for (const n of fixed) out[n.id] = { x: Number(stored[n.id].x), y: Number(stored[n.id].y) }; + const originX = Math.min(...fixed.map(n => out[n.id].x)); + const fixedBottom = Math.max(...fixed.map(n => out[n.id].y + estimateNodeHeight(n))); + // New cards go in a grid below the saved ones, so they never cover a saved card. + const bottom = placeGrid(group.nodes.filter(n => !out[n.id]), originX, fixedBottom + ROW_GAP, out); + placedBottom.push(Math.max(fixedBottom, bottom)); + } + let y = placedBottom.length ? Math.max(...placedBottom) + ZONE_PADDING + ZONE_GAP : 0; + for (const group of fresh) { + y = placeGrid(group.nodes, 0, y + ZONE_HEADER + ZONE_PADDING, out) + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Places nodes in a grid from (originX, top): applications, then services, then + * databases, each type in its own column(s) that wrap after MAX_ROWS cards. + * Returns the bottom of the lowest card (top when there are no nodes). + */ +function placeGrid(nodes, originX, top, out) { + let firstColumn = 0; + let bottom = top; + for (const column of [0, 1, 2]) { + const ofType = nodes.filter(n => columnOf(n) === column); + const nextY = []; + ofType.forEach((n, i) => { + const sub = Math.floor(i / MAX_ROWS); + nextY[sub] ??= top; + out[n.id] = { x: columnLeft(originX, firstColumn + sub), y: nextY[sub] }; + nextY[sub] += estimateNodeHeight(n) + ROW_GAP; + }); + firstColumn += nextY.length; + bottom = Math.max(bottom, ...nextY.map(v => v - ROW_GAP)); + } + return bottom; +} + +// ── Server view: project / environment groups, linked cards side by side ── + +export const GROUP_PADDING = 16; +export const GROUP_HEADER = 32; +export const GROUP_GAP = 32; +export const GROUPS_PER_ROW = 3; +export const SINGLE_COLUMNS = 3; + +const contextGroups = nodes => { + const groups = new Map(); + for (const n of nodes) { + const key = n.context || ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(n); + } + // Server view: the unmanaged containers group comes first, so it is not lost below many projects. + const unmanaged = ([, members]) => (members[0]?.type === 'container' ? 0 : 1); + return [...groups.entries()].sort((a, b) => unmanaged(a) - unmanaged(b) || a[0].localeCompare(b[0])); +}; +const byTypeAndName = (a, b) => columnOf(a) - columnOf(b) || String(a.name).localeCompare(String(b.name)); + +/** + * Card positions inside one group, from (0, 0). Linked cards form chains from + * left to right (source before target, one column per step); every chain gets + * its own rows. Cards without links follow in a grid of SINGLE_COLUMNS. + */ +export function layoutBlock(nodes, links = []) { + const ids = new Set(nodes.map(n => n.id)); + const inside = links.filter(l => ids.has(l.source) && ids.has(l.target) && l.source !== l.target); + const parent = new Map(nodes.map(n => [n.id, n.id])); + const find = id => (parent.get(id) === id ? id : find(parent.get(id))); + for (const l of inside) parent.set(find(l.source), find(l.target)); + const order = [...nodes].sort(byTypeAndName); + const components = new Map(); + for (const n of order) { + const root = find(n.id); + if (!components.has(root)) components.set(root, []); + components.get(root).push(n); + } + + const out = {}; + const step = CARD_WIDTH + COLUMN_GAP; + let top = 0; + let columns = 0; + const singles = []; + for (const members of components.values()) { + if (members.length === 1) { + singles.push(members[0]); + continue; + } + // Depth: the longest path from a card without incoming links (cycles stop at the size of the chain). + const own = new Set(members.map(n => n.id)); + const edges = inside.filter(l => own.has(l.source)); + const depth = new Map(members.filter(n => !edges.some(l => l.target === n.id)).map(n => [n.id, 0])); + if (!depth.size) depth.set(members[0].id, 0); + for (let changed = true, rounds = 0; changed && rounds < members.length; rounds++) { + changed = false; + for (const l of edges) { + const next = (depth.get(l.source) ?? -1) + 1; + if (next > 0 && next > (depth.get(l.target) ?? -1)) { + depth.set(l.target, next); + changed = true; + } + } + } + const nextY = []; + for (const n of members) { + const d = depth.get(n.id) ?? 0; + nextY[d] ??= top; + out[n.id] = { x: d * step, y: nextY[d] }; + nextY[d] += estimateNodeHeight(n) + ROW_GAP; + } + columns = Math.max(columns, nextY.length); + top = Math.max(...nextY.filter(v => v !== undefined)) + ROW_GAP; + } + let rowHeight = 0; + singles.forEach((n, i) => { + const column = i % SINGLE_COLUMNS; + if (column === 0 && i > 0) { + top += rowHeight + ROW_GAP; + rowHeight = 0; + } + out[n.id] = { x: column * step, y: top }; + rowHeight = Math.max(rowHeight, estimateNodeHeight(n)); + }); + if (singles.length) columns = Math.max(columns, Math.min(singles.length, SINGLE_COLUMNS)); + const bottom = singles.length ? top + rowHeight : Math.max(0, top - ROW_GAP); + return { positions: out, width: columns * step - COLUMN_GAP, height: bottom }; +} + +/** + * A project / environment group with cards saved on its environment canvas: + * the same placement as that canvas (saved cards, then a grid below them), + * moved so the top-left card starts at (0, 0). Groups without saved cards use layoutBlock. + */ +export function layoutStoredBlock(nodes, stored, links = []) { + if (!nodes.some(n => isPoint(stored[n.id]))) return layoutBlock(nodes, links); + const zone = nodes[0].zone; + const placed = layoutGraph({ zones: [{ id: zone }], nodes: nodes.map(n => ({ ...n, zone })), positions: stored }); + const minX = Math.min(...nodes.map(n => placed[n.id].x)); + const minY = Math.min(...nodes.map(n => placed[n.id].y)); + const positions = Object.fromEntries(nodes.map(n => [n.id, { x: placed[n.id].x - minX, y: placed[n.id].y - minY }])); + return { + positions, + origin: { x: minX, y: minY }, + width: Math.max(...nodes.map(n => positions[n.id].x + CARD_WIDTH)), + height: Math.max(...nodes.map(n => positions[n.id].y + estimateNodeHeight(n))), + }; +} + +/** + * Top-left of the given cards where their environment canvas places them. A group saved for the first time + * from the server view goes there, so it does not cover the cards of the environment on other servers. + */ +function environmentOrigin(graph, nodes) { + const environment = nodes[0]?.environment?.uuid; + if (!environment) return { x: 0, y: 0 }; + const placed = layoutGraph({ + zones: graph.zones, + nodes: (graph.nodes || []).filter(n => n.environment?.uuid === environment), + positions: storedPositions(graph), + }); + return { x: Math.min(...nodes.map(n => placed[n.id].x)), y: Math.min(...nodes.map(n => placed[n.id].y)) }; +} + +/** + * Server view: zones stack downwards; inside a zone, one block per project / environment, GROUPS_PER_ROW per row. + * When `offsets` is given, it gets per card the shift from its environment canvas position to its server view + * position, so a moved card can be saved on its environment canvas. + */ +export function layoutServerView(graph, offsets = null) { + const stored = storedPositions(graph); + const out = {}; + let y = 0; + for (const zone of zoneGroups(graph)) { + const innerTop = y + ZONE_HEADER + ZONE_PADDING; + let x = 0; + let rowTop = innerTop; + let rowBottom = innerTop; + contextGroups(zone.nodes).forEach(([, nodes], i) => { + if (i > 0 && i % GROUPS_PER_ROW === 0) { + x = 0; + rowTop = rowBottom + GROUP_GAP; + } + const block = layoutStoredBlock(nodes, stored, graph.links || []); + const origin = offsets ? (block.origin ?? environmentOrigin(graph, nodes)) : null; + for (const [id, p] of Object.entries(block.positions)) { + out[id] = { x: x + GROUP_PADDING + p.x, y: rowTop + GROUP_HEADER + GROUP_PADDING + p.y }; + if (offsets) offsets[id] = { x: out[id].x - origin.x - p.x, y: out[id].y - origin.y - p.y }; + } + x += block.width + GROUP_PADDING * 2 + GROUP_GAP; + rowBottom = Math.max(rowBottom, rowTop + GROUP_HEADER + GROUP_PADDING * 2 + block.height); + }); + y = rowBottom + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Shared variables view: variable cards in one column on the left (by name). Resources that use a + * variable sit next to them in one column, grouped by project / environment, so every link runs + * straight from left to right. Other resources follow below in the server view grid. + */ +export function layoutConnectView(graph) { + const out = {}; + const groups = zoneGroups(graph); + const variables = groups.find(g => g.id === SOURCES_ZONE); + const connected = groups.find(g => g.id === CONNECTED_ZONE); + let y = ZONE_HEADER + ZONE_PADDING; + for (const n of [...(variables?.nodes || [])].sort((a, b) => String(a.name).localeCompare(String(b.name)))) { + out[n.id] = { x: 0, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + const offset = variables ? CARD_WIDTH + ZONE_PADDING * 2 + ZONE_GAP * 3 : 0; + let top = 0; + if (connected) { + y = ZONE_HEADER + ZONE_PADDING; + for (const [, nodes] of contextGroups(connected.nodes)) { + y += GROUP_HEADER + GROUP_PADDING; + for (const n of [...nodes].sort(byTypeAndName)) { + out[n.id] = { x: offset + GROUP_PADDING, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + y += GROUP_PADDING - ROW_GAP + GROUP_GAP; + } + top = y - GROUP_GAP + ZONE_PADDING + ZONE_GAP; + } + const others = groups.filter(g => g.id !== SOURCES_ZONE && g.id !== CONNECTED_ZONE); + const placed = layoutServerView({ zones: others.map(g => g.zone || { id: g.id }), nodes: others.flatMap(g => g.nodes) }); + for (const [id, p] of Object.entries(placed)) out[id] = { x: p.x + offset, y: p.y + top }; + return out; +} + +/** + * Shared variables view: moves the resources that use a shown variable into the "connected" zone, + * so they show apart from the other resources. Runs after filterGraph and after every link change. + */ +export function groupByUse(graph) { + if (graph?.layout !== 'connect') return graph; + const used = new Set((graph.links || []).map(l => l.target)); + return { + ...graph, + nodes: (graph.nodes || []).map(n => (!isSource(n) && used.has(n.id) ? { ...n, zone: CONNECTED_ZONE } : n)), + }; +} + +/** Frames of the project / environment groups of the given cards (server view). */ +export function groupFrames(nodes, positions, sizes = {}) { + const frames = []; + for (const [name, members] of contextGroups(nodes)) { + const rects = members.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + frames.push({ + name, + link: members[0].context_link, + x: minX - GROUP_PADDING, + y: minY - GROUP_PADDING - GROUP_HEADER, + width: Math.max(...rects.map(r => r.x + r.width)) - minX + GROUP_PADDING * 2, + height: Math.max(...rects.map(r => r.y + r.height)) - minY + GROUP_PADDING * 2 + GROUP_HEADER, + }); + } + return frames; +} + +/** Card rect from positions and measured sizes (falls back to the estimate). */ +export function cardRect(node, positions, sizes = {}) { + const p = positions?.[node.id]; + if (!p) return null; + return { + x: p.x, + y: p.y, + width: sizes[node.id]?.width ?? CARD_WIDTH, + height: sizes[node.id]?.height ?? estimateNodeHeight(node), + }; +} + +/** Frame per zone: bbox of its cards (or of its group frames) + padding, with room for the header on top. */ +export function zoneBoxes(graph, positions, sizes = {}) { + const boxes = []; + for (const group of zoneGroups(graph)) { + const rects = group.nodes.some(n => n.context) + ? groupFrames(group.nodes, positions, sizes) + : group.nodes.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + const maxX = Math.max(...rects.map(r => r.x + r.width)); + const maxY = Math.max(...rects.map(r => r.y + r.height)); + boxes.push({ + id: group.id, + name: group.name, + zone: group.zone, + nodes: group.nodes.map(n => n.id), + x: minX - ZONE_PADDING, + y: minY - ZONE_PADDING - ZONE_HEADER, + width: maxX - minX + ZONE_PADDING * 2, + height: maxY - minY + ZONE_PADDING * 2 + ZONE_HEADER, + }); + } + return boxes; +} + +const toBox = r => ({ x: r.x, y: r.y, w: r.width, h: r.height }); +// A frame reaches `side` past its cards left, right and below, and `top` above them. +const grow = (b, side, top) => ({ x: b.x - side, y: b.y - top, w: b.w + side * 2, h: b.h + side + top }); +const frameAround = (boxes, side, top) => { + const x = Math.min(...boxes.map(b => b.x)); + const y = Math.min(...boxes.map(b => b.y)); + return grow({ x, y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: Math.max(...boxes.map(b => b.y + b.h)) - y }, side, top); +}; + +/** + * What a dragged card must keep clear of, for freeSpot(): `others` are the + * other cards and the other zone frames (and, server view, the other project / + * environment frames in its zone), grown by the room the card's own frames + * take around it. `fits` checks the card's own frames, which also span its + * other cards, against those frames. + */ +export function dragRules(graph, positions, sizes, id) { + const node = graph.nodes.find(n => n.id === id); + const own = zoneGroups(graph).find(z => z.nodes.some(n => n.id === id)); + const grouped = !!own?.nodes.some(n => n.context); + const context = n => n.context || ''; + const rects = nodes => nodes.filter(n => n.id !== id).map(n => cardRect(n, positions, sizes)).filter(Boolean).map(toBox); + const groupSide = grouped ? GROUP_PADDING : 0; + const groupTop = grouped ? GROUP_PADDING + GROUP_HEADER : 0; + const side = ZONE_PADDING + groupSide; + const top = ZONE_PADDING + ZONE_HEADER + groupTop; + const zones = zoneBoxes(graph, positions, sizes).filter(z => !z.nodes.includes(id)).map(toBox); + const groups = grouped ? groupFrames(own.nodes.filter(n => context(n) !== context(node)), positions, sizes).map(toBox) : []; + const zoneMates = rects(own?.nodes || []); + const groupMates = grouped ? rects(own.nodes.filter(n => context(n) === context(node))) : []; + // The other box must keep the frame's room on the side that faces the card. + const room = (b, s, t) => ({ x: b.x - s, y: b.y - s, w: b.w + s * 2, h: b.h + s + t }); + return { + others: [...rects(graph.nodes), ...zones.map(z => room(z, side, top)), ...groups.map(g => room(g, groupSide, groupTop))], + fits: box => zones.every(z => apart(frameAround([...zoneMates, box], side, top), z, BOX_GAP)) + && (!grouped || groups.every(g => apart(frameAround([...groupMates, box], groupSide, groupTop), g, BOX_GAP))), + }; +} + +/** + * SVG path between two card rects. Target right of source: right edge → + * left edge; target left of source: left edge → right edge; overlapping + * columns (stacked): bottom → top (or top → bottom when the target is above). + */ +export function linkPath(a, b) { + let side; + let from; + let to; + let c1; + let c2; + if (b.x >= a.x + a.width) { + side = 'right'; + from = { x: a.x + a.width, y: a.y + a.height / 2 }; + to = { x: b.x, y: b.y + b.height / 2 }; + } else if (b.x + b.width <= a.x) { + side = 'left'; + from = { x: a.x, y: a.y + a.height / 2 }; + to = { x: b.x + b.width, y: b.y + b.height / 2 }; + } else if (b.y + b.height / 2 >= a.y + a.height / 2) { + side = 'down'; + from = { x: a.x + a.width / 2, y: a.y + a.height }; + to = { x: b.x + b.width / 2, y: b.y }; + } else { + side = 'up'; + from = { x: a.x + a.width / 2, y: a.y }; + to = { x: b.x + b.width / 2, y: b.y + b.height }; + } + if (side === 'right' || side === 'left') { + const dx = Math.max(40, Math.abs(to.x - from.x) / 2) * (side === 'right' ? 1 : -1); + c1 = { x: from.x + dx, y: from.y }; + c2 = { x: to.x - dx, y: to.y }; + } else { + const dy = Math.max(40, Math.abs(to.y - from.y) / 2) * (side === 'down' ? 1 : -1); + c1 = { x: from.x, y: from.y + dy }; + c2 = { x: to.x, y: to.y - dy }; + } + const mid = { + x: (from.x + 3 * c1.x + 3 * c2.x + to.x) / 8, + y: (from.y + 3 * c1.y + 3 * c2.y + to.y) / 8, + }; + const r = v => Math.round(v * 10) / 10; + const d = `M ${r(from.x)} ${r(from.y)} C ${r(c1.x)} ${r(c1.y)}, ${r(c2.x)} ${r(c2.y)}, ${r(to.x)} ${r(to.y)}`; + return { d, side, from, to, mid }; +} + +const BUSY = ['starting', 'restarting', 'created', 'removing', 'in_progress', 'queued', 'deploying', 'building']; +const BAD = ['exited', 'stopped', 'dead', 'failed', 'error']; + +/** Coolify status string → 'ok' | 'warn' | 'bad' | 'idle' | 'busy'. */ +export function statusTone(status) { + const value = String(status ?? '').trim().toLowerCase(); + if (!value) return 'idle'; + const [state, health = ''] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + if (BUSY.includes(state)) return 'busy'; + if (BAD.includes(state)) return 'bad'; + if (state === 'degraded') return 'warn'; + if (state === 'running' || state === 'healthy') return health === 'unhealthy' ? 'warn' : 'ok'; + return 'idle'; +} + +/** "running:healthy" → "Running (healthy)". */ +export function statusLabel(status) { + const value = String(status ?? '').trim(); + if (!value) return 'Unknown'; + const [state, health] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + const word = state.charAt(0).toUpperCase() + state.slice(1).replace(/_/g, ' '); + return health ? `${word} (${health})` : word; +} + +/** + * Connect and server views: projects of the resources, each with its environments, sorted by name. + * @returns {{uuid: string, name: string, environments: {uuid: string, name: string}[]}[]} + */ +export function filterOptions(graph) { + const projects = new Map(); + for (const n of graph?.nodes || []) { + if (!n.project || !n.environment) continue; + if (!projects.has(n.project.uuid)) projects.set(n.project.uuid, { ...n.project, environments: new Map() }); + projects.get(n.project.uuid).environments.set(n.environment.uuid, n.environment); + } + const byName = (a, b) => String(a.name).localeCompare(String(b.name)); + return [...projects.values()] + .map(p => ({ uuid: p.uuid, name: p.name, environments: [...p.environments.values()].sort(byName) })) + .sort(byName); +} + +/** + * Connect and server views: only the resources of the chosen project / environment and their links. + * Variables always stay, so they can be added to the shown resources. + */ +export function filterGraph(graph, filter = {}) { + if (!graph || (!filter.project && !filter.environment)) return graph; + const keep = n => isSource(n) + || ((!filter.project || n.project?.uuid === filter.project) && (!filter.environment || n.environment?.uuid === filter.environment)); + const nodes = (graph.nodes || []).filter(keep); + const ids = new Set(nodes.map(n => n.id)); + return { ...graph, nodes, links: (graph.links || []).filter(l => ids.has(l.source) && ids.has(l.target)) }; +} + +/** + * Connect view: links go from a shared variable (not a deleted one) or a tag to a resource. + * Server view: links are saved on an environment canvas, so both cards must be in the same environment. + */ +export function canConnect(graph, source, target) { + if (graph?.layout === 'server') { + const environment = graph.nodes?.find(n => n.id === source)?.environment?.uuid; + return !!environment && source !== target && graph.nodes.find(n => n.id === target)?.environment?.uuid === environment; + } + if (graph?.layout !== 'connect') return !!source && !!target && source !== target; + const from = graph.nodes?.find(n => n.id === source); + const to = graph.nodes?.find(n => n.id === target); + return isSource(from) && !from.missing && !!to && !isSource(to); +} + +/** A link may be drawn between two different nodes that are not linked yet (either direction). */ +export function canLink(links, source, target) { + if (!source || !target || source === target) return false; + return !(links || []).some(l => (l.source === source && l.target === target) || (l.source === target && l.target === source)); +} + +// ── Card and panel constants (used by the Svelte components) ────────── + +// Application and service events go to the team channel; database events go to the user channel. +export const STATUS_CHANNELS = [['team', ['ApplicationStatusChanged', 'ServiceStatusChanged']], ['user', ['DatabaseStatusChanged']]]; +export const TYPE_LABELS = { application: 'Application', service: 'Service', database: 'Database', container: 'Unmanaged container', variable: 'Shared variable', tag: 'Tag' }; +// Wording of the connect view, per data-mode (shared variables or tags). +export const CONNECT_TEXT = { + variables: { + source: 'Variable', + added: (source, target) => `Added ${source} to ${target}. Redeploy it to use the variable.`, + removed: (source, target) => `Removed ${source} from ${target}. Redeploy it to apply.`, + already: (source, target) => `${target} already uses ${source}.`, + usedBy: 'Used by', + noTargets: 'No resource uses this variable.', + allTargets: 'Every resource already uses this variable.', + port: 'Drag to a resource to add this variable to it', + noSources: 'This resource uses no shared variable of this scope.', + sources: 'Shared variables', + addSources: 'Add shared variables', + searchSources: 'Search variables', + noMoreSources: 'No other shared variable to add.', + note: node => `Adding creates a ${node.name} variable with the value ${node.reference} on the resource. Redeploy or restart the resource to apply changes.`, + viewOnly: 'You can look around. Only team admins can add shared variables to resources.', + empty: 'No shared variables or resources yet', + }, + tags: { + source: 'Tag', + added: (source, target) => `Tagged ${target} with ${source}.`, + removed: (source, target) => `Removed the ${source} tag from ${target}.`, + already: (source, target) => `${target} already has the ${source} tag.`, + usedBy: 'Tagged', + noTargets: 'No resource has this tag.', + allTargets: 'Every resource already has this tag.', + port: 'Drag to a resource to tag it', + noSources: 'This resource has no tag.', + sources: 'Tags', + addSources: 'Add tags', + searchSources: 'Search tags', + noMoreSources: 'No other tag to add. Create tags on the Tags page.', + note: () => 'The deploy webhook of a tag deploys every tagged application and service.', + viewOnly: 'You can look around. Only team admins can tag resources.', + empty: 'No tags or resources yet', + }, +}; +export const TONE_DOT = { + ok: 'bg-emerald-500', + warn: 'bg-warning', + bad: 'bg-red-500', + idle: 'bg-neutral-400 dark:bg-neutral-500', + busy: 'bg-warning', +}; +// Outline icons, drawn from local paths. +export const ICONS = { + application: ['M3 6.5A2.5 2.5 0 0 1 5.5 4h13A2.5 2.5 0 0 1 21 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5z', 'M3 9h18', 'M9.5 13l-2 2 2 2', 'M14.5 13l2 2-2 2'], + service: ['M12 4 3 8.5l9 4.5 9-4.5z', 'M3 12.5l9 4.5 9-4.5', 'M3 16.5l9 4.5 9-4.5'], + database: ['M5 6c0-1.66 3.13-3 7-3s7 1.34 7 3-3.13 3-7 3-7-1.34-7-3z', 'M5 6v12c0 1.66 3.13 3 7 3s7-1.34 7-3V6', 'M5 12c0 1.66 3.13 3 7 3s7-1.34 7-3'], + container: ['M12 3 4 7.5v9L12 21l8-4.5v-9z', 'M4 7.5 12 12l8-4.5', 'M12 12v9'], + server: ['M4 4h16v6H4z', 'M4 14h16v6H4z', 'M7.5 7h.01', 'M7.5 17h.01'], + refresh: ['M20 11a8 8 0 0 0-14.9-3.9', 'M4 4v4h4', 'M4 13a8 8 0 0 0 14.9 3.9', 'M20 20v-4h-4'], + plus: ['M12 5v14', 'M5 12h14'], + minus: ['M5 12h14'], + fit: ['M4 9V4h5', 'M20 9V4h-5', 'M4 15v5h5', 'M20 15v5h-5'], + x: ['M6 6l12 12', 'M18 6 6 18'], + external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4'], + link: ['M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1', 'M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1'], + variable: ['M8 4H7a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2 2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1', 'M16 4h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2 2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-1'], + search: ['M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z', 'M20 20l-4-4'], + tag: ['M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z', 'M7.5 7.5h.01'], + info: ['M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z', 'M12 11v5', 'M12 8h.01'], +}; + +/** A saved { x, y, z } canvas view, or null when it is missing or not valid. */ +export function readView(key, storage = globalThis.localStorage) { + try { + const v = JSON.parse(storage?.getItem(key) ?? 'null'); + const ok = v && [v.x, v.y, v.z].every(Number.isFinite) && v.z >= MIN_ZOOM && v.z <= MAX_ZOOM; + return ok ? { x: v.x, y: v.y, z: v.z } : null; + } catch { + return null; + } +} + +// Small screens: a notice that the canvas is not optimized for mobile. Shown below 768px (md); +// closed once, it stays closed on every canvas (same key as the deploy canvas notice). +export const MOBILE_NOTICE_KEY = 'coolify-canvas:mobile-notice-dismissed'; diff --git a/resources/js/environment-canvas.test.js b/resources/js/environment-canvas.test.js new file mode 100644 index 0000000000..5d7c096be9 --- /dev/null +++ b/resources/js/environment-canvas.test.js @@ -0,0 +1,437 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + CARD_WIDTH, + MAX_ROWS, + COLUMN_GAP, + ROW_GAP, + ZONE_HEADER, + ZONE_PADDING, + canConnect, + canLink, + dragRules, + filterGraph, + groupByUse, + filterOptions, + groupFrames, + layoutBlock, + estimateNodeHeight, + layoutGraph, + layoutServerView, + linkPath, + statusLabel, + statusTone, + zoneBoxes, + readView, +} from './environment-canvas.js'; +import { freeSpot } from './canvas-core.js'; + +const node = (id, type, zone, extra = {}) => ({ id, type, zone, name: id, status: 'running:healthy', extra_zones: [], urls: [], children: [], volumes: [], ...extra }); +const graph = (nodes, extra = {}) => ({ + zones: [{ id: 's1', name: 'One' }, { id: 's2', name: 'Two' }], + nodes, + links: [], + positions: {}, + ...extra, +}); +const column = n => n * (CARD_WIDTH + COLUMN_GAP); + +test('places applications, services, and databases in their own columns', () => { + const p = layoutGraph(graph([node('db', 'database', 's1'), node('app', 'application', 's1'), node('svc', 'service', 's1')])); + assert.equal(p.app.x, column(0)); + assert.equal(p.svc.x, column(1)); + assert.equal(p.db.x, column(2)); + const top = ZONE_HEADER + ZONE_PADDING; + assert.deepEqual([p.app.y, p.svc.y, p.db.y], [top, top, top]); +}); + +test('stacks rows of the same type inside a column', () => { + const first = node('a1', 'application', 's1', { urls: ['https://a.test'] }); + const p = layoutGraph(graph([first, node('a2', 'application', 's1')])); + assert.equal(p.a2.x, p.a1.x); + assert.equal(p.a2.y, p.a1.y + estimateNodeHeight(first) + ROW_GAP); +}); + +test('wraps a long list into another column and shifts later types right', () => { + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`a${i}`, 'application', 's1')); + const p = layoutGraph(graph([...apps, node('db', 'database', 's1')])); + assert.equal(p[`a${MAX_ROWS - 1}`].x, column(0)); + assert.deepEqual(p[`a${MAX_ROWS}`], { x: column(1), y: ZONE_HEADER + ZONE_PADDING }); + assert.equal(p.db.x, column(2)); +}); + +test('stacks zones in graph order with the "No server" zone last', () => { + const p = layoutGraph(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')])); + assert.ok(p.a.y < p.b.y, 'first server zone on top'); + assert.ok(p.b.y < p.orphan.y, 'no-server zone at the bottom'); + const boxes = zoneBoxes(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')]), p); + assert.deepEqual(boxes.map(b => b.name), ['One', 'Two', 'No server']); + for (let i = 1; i < boxes.length; i++) assert.ok(boxes[i].y > boxes[i - 1].y + boxes[i - 1].height, 'zones do not overlap'); +}); + +test('treats a zone id that is not in the zone list as no server', () => { + const g = graph([node('x', 'database', 'gone')]); + assert.deepEqual(zoneBoxes(g, layoutGraph(g)).map(b => b.id), [null]); +}); + +test('stored positions win over auto placement', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: { app: { x: 500, y: -40 } } })); + assert.deepEqual(p.app, { x: 500, y: -40 }); +}); + +test('treats an empty positions list from PHP as nothing stored', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: [] })); + assert.equal(p.app.x, 0); +}); + +test('places new nodes in a wrapped grid below the stored cards of their zone', () => { + const stored = node('a1', 'application', 's1'); + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`n${i}`, 'application', 's1')); + const p = layoutGraph(graph([stored, ...apps, node('d1', 'database', 's1')], { positions: { a1: { x: 100, y: 200 } } })); + const top = 200 + estimateNodeHeight(stored) + ROW_GAP; + assert.deepEqual(p.n0, { x: 100, y: top }); + assert.deepEqual(p[`n${MAX_ROWS}`], { x: 100 + column(1), y: top }, 'wraps after MAX_ROWS cards'); + assert.deepEqual(p.d1, { x: 100 + column(2), y: top }, 'databases go right of the application columns'); +}); + +test('auto-placed zones go below zones with stored cards', () => { + const p = layoutGraph(graph([node('a', 'application', 's1'), node('b', 'application', 's2')], { positions: { a: { x: 0, y: 1000 } } })); + assert.ok(p.b.y > 1000 + estimateNodeHeight(node('a', 'application', 's1')) + ZONE_PADDING + ZONE_HEADER); +}); + +test('estimates taller cards for urls, chips, and children', () => { + const plain = estimateNodeHeight(node('a', 'service', 's1')); + const rich = estimateNodeHeight(node('b', 'service', 's1', { urls: ['https://x'], volumes: ['v'], children: [{ name: 'c' }, { name: 'd' }] })); + assert.ok(rich > plain); + const capped = estimateNodeHeight(node('c', 'service', 's1', { children: Array.from({ length: 20 }, (_, i) => ({ name: `c${i}` })) })); + const six = estimateNodeHeight(node('d', 'service', 's1', { children: Array.from({ length: 6 }, (_, i) => ({ name: `c${i}` })) })); + assert.equal(capped, six, 'children beyond the cap add one "+N more" row'); +}); + +test('zone boxes wrap their cards with padding and header', () => { + const g = graph([node('a', 'application', 's1'), node('d', 'database', 's1')]); + const positions = { a: { x: 0, y: 0 }, d: { x: 400, y: 50 } }; + const sizes = { a: { width: 260, height: 100 }, d: { width: 260, height: 80 } }; + const [box] = zoneBoxes(g, positions, sizes); + assert.deepEqual( + { id: box.id, x: box.x, y: box.y, width: box.width, height: box.height, nodes: box.nodes }, + { id: 's1', x: -ZONE_PADDING, y: -ZONE_PADDING - ZONE_HEADER, width: 660 + ZONE_PADDING * 2, height: 130 + ZONE_PADDING * 2 + ZONE_HEADER, nodes: ['a', 'd'] }, + ); +}); + +test('zone boxes skip zones without cards', () => { + assert.deepEqual(zoneBoxes(graph([node('a', 'application', 's2')]), { a: { x: 0, y: 0 } }).map(b => b.id), ['s2']); +}); + +const rect = (x, y) => ({ x, y, width: 260, height: 100 }); + +test('links go from the right edge to the left edge when the target is on the right', () => { + const path = linkPath(rect(0, 0), rect(400, 200)); + assert.equal(path.side, 'right'); + assert.deepEqual(path.from, { x: 260, y: 50 }); + assert.deepEqual(path.to, { x: 400, y: 250 }); + assert.match(path.d, /^M 260 50 C /); +}); + +test('links go from the left edge to the right edge when the target is on the left', () => { + const path = linkPath(rect(400, 0), rect(0, 0)); + assert.equal(path.side, 'left'); + assert.deepEqual(path.from, { x: 400, y: 50 }); + assert.deepEqual(path.to, { x: 260, y: 50 }); +}); + +test('links run vertically between stacked cards', () => { + const down = linkPath(rect(0, 0), rect(40, 300)); + assert.equal(down.side, 'down'); + assert.deepEqual([down.from, down.to], [{ x: 130, y: 100 }, { x: 170, y: 300 }]); + const up = linkPath(rect(0, 300), rect(0, 0)); + assert.equal(up.side, 'up'); + assert.deepEqual([up.from, up.to], [{ x: 130, y: 300 }, { x: 130, y: 100 }]); +}); + +test('link midpoint sits between both ends', () => { + const { mid } = linkPath(rect(0, 0), rect(600, 0)); + assert.deepEqual(mid, { x: 430, y: 50 }); +}); + +test('maps Coolify statuses to tones', () => { + const cases = { + 'running:healthy': 'ok', + 'running:unknown': 'ok', + running: 'ok', + 'running (healthy)': 'ok', + 'running:unhealthy': 'warn', + 'degraded:unhealthy': 'warn', + 'exited:unhealthy': 'bad', + exited: 'bad', + stopped: 'bad', + restarting: 'busy', + 'starting:unknown': 'busy', + paused: 'idle', + '': 'idle', + null: 'idle', + }; + for (const [status, tone] of Object.entries(cases)) { + assert.equal(statusTone(status === 'null' ? null : status), tone, status); + } +}); + +test('formats status labels', () => { + assert.equal(statusLabel('running:healthy'), 'Running (healthy)'); + assert.equal(statusLabel('exited'), 'Exited'); + assert.equal(statusLabel(''), 'Unknown'); +}); + +test('refuses self links and duplicates in either direction', () => { + const links = [{ id: 'l1', source: 'a', target: 'b', label: null }]; + assert.equal(canLink(links, 'a', 'a'), false); + assert.equal(canLink(links, 'a', 'b'), false); + assert.equal(canLink(links, 'b', 'a'), false); + assert.equal(canLink(links, 'a', 'c'), true); +}); + +test('a server view card has the same height as on the environment canvas', () => { + const card = { id: 'a', type: 'application' }; + // The group frame names the project / environment, so the card has no extra line for it. + assert.equal(estimateNodeHeight({ ...card, context: 'Acme / production' }), estimateNodeHeight(card)); +}); + +// ── Server view ── +const srv = (id, type, context, extra = {}) => ({ id, type, name: id, zone: 'z1', context, ...extra }); + +test('server view: a chain of linked cards runs left to right, source before target', () => { + const nodes = [srv('db', 'database', 'A / prod'), srv('web', 'application', 'A / prod'), srv('api', 'application', 'A / prod')]; + const { positions } = layoutBlock(nodes, [{ source: 'web', target: 'api' }, { source: 'api', target: 'db' }]); + assert.equal(positions.web.y, positions.api.y); + assert.equal(positions.api.y, positions.db.y); + assert.ok(positions.web.x < positions.api.x && positions.api.x < positions.db.x); + assert.equal(positions.api.x - positions.web.x, CARD_WIDTH + COLUMN_GAP); +}); + +test('server view: cards without links go below the chains, in a grid', () => { + const nodes = [srv('a', 'application', 'g'), srv('b', 'application', 'g'), srv('c', 'service', 'g'), srv('d', 'database', 'g'), srv('e', 'database', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'a', target: 'd' }]); + assert.equal(positions.a.y, positions.d.y); // linked pair side by side + for (const id of ['b', 'c', 'e']) assert.ok(positions[id].y > positions.a.y, `${id} is below the chain`); + assert.equal(positions.b.y, positions.c.y); // one grid row +}); + +test('server view: a link cycle still gets every card a position', () => { + const nodes = [srv('x', 'application', 'g'), srv('y', 'application', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'x', target: 'y' }, { source: 'y', target: 'x' }]); + assert.ok(positions.x && positions.y); + assert.notDeepEqual(positions.x, positions.y); +}); + +test('server view: one frame per project / environment, and the frames do not overlap', () => { + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [{ id: 'l', source: 'a', target: 'b' }], + positions: {}, + }; + const positions = layoutGraph(graph); + const frames = groupFrames(graph.nodes, positions); + assert.deepEqual(frames.map(f => f.name), ['Blog / prod', 'Shop / prod']); + const [f1, f2] = frames; + assert.ok(f1.x + f1.width <= f2.x || f2.x + f2.width <= f1.x || f1.y + f1.height <= f2.y || f2.y + f2.height <= f1.y); + assert.equal(positions.a.y, positions.b.y); +}); + +test('server view: a group keeps the arrangement saved on its environment canvas', () => { + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'service', 'Shop / prod')], + links: [], + positions: { a: { x: 900, y: 500 }, b: { x: 600, y: 800 } }, + }; + const positions = layoutGraph(graph); + assert.equal(positions.a.x - positions.b.x, 300); + assert.equal(positions.b.y - positions.a.y, 300); + assert.ok(positions.c.y >= positions.b.y + estimateNodeHeight(graph.nodes[1]), 'a card without a saved position goes below'); +}); + +const envNode = (id, type, zone, environment, extra = {}) => srv(id, type, `Shop / ${environment}`, { zone, project: { uuid: 'p' }, environment: { uuid: environment }, ...extra }); + +test('server view: cards link only to cards of the same environment', () => { + const g = { + layout: 'server', + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z2', 'prod'), envNode('c', 'application', 'z1', 'dev'), { id: 'container:1', type: 'container', zone: 'z1' }], + }; + assert.equal(canConnect(g, 'a', 'b'), true); + assert.equal(canConnect(g, 'a', 'c'), false); + assert.equal(canConnect(g, 'a', 'a'), false); + assert.equal(canConnect(g, 'a', 'container:1'), false); + assert.equal(canConnect(g, 'container:1', 'a'), false); +}); + +test('server view: a moved group saved on its environment canvas keeps its arrangement', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }, { id: 'z2', name: 'db' }], + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z1', 'prod'), envNode('c', 'service', 'z2', 'prod'), envNode('d', 'application', 'z1', 'dev')], + links: [{ id: 'l', source: 'a', target: 'b' }], + positions: {}, + }; + const offsets = {}; + const positions = layoutServerView(g, offsets); + // The user drags b 100px down; the canvas saves its group (a, b) in environment canvas coordinates. + positions.b = { x: positions.b.x, y: positions.b.y + 100 }; + const saved = Object.fromEntries(['a', 'b'].map(id => [id, { x: positions[id].x - offsets[id].x, y: positions[id].y - offsets[id].y }])); + const again = layoutServerView({ ...g, positions: saved }); + assert.deepEqual(again.a, positions.a); + assert.deepEqual(again.b, positions.b); + // On the environment canvas the saved cards do not cover the card of the same environment on the other server. + const environment = layoutGraph({ zones: g.zones, nodes: g.nodes.filter(n => n.environment.uuid === 'prod'), positions: saved }); + const bottom = Math.max(...['a', 'b'].map(id => environment[id].y + estimateNodeHeight(g.nodes.find(n => n.id === id)))); + assert.ok(environment.c.y > bottom); +}); + +test('variables view: variables stack on the left by name, resources sit to their right', () => { + const g = { + layout: 'connect', + zones: [{ id: 'sources', name: 'Team variables', icon: 'variable' }, { id: 'resources', name: 'Resources', icon: 'service' }], + nodes: [ + node('variable:2', 'variable', 'sources', { name: 'TOKEN' }), + node('variable:1', 'variable', 'sources', { name: 'API_URL' }), + node('app', 'application', 'resources', { context: 'Shop / prod' }), + ], + links: [{ id: 'variable:1|app', source: 'variable:1', target: 'app' }], + positions: {}, + }; + const p = layoutGraph(g); + + assert.equal(p['variable:1'].x, 0); + assert.equal(p['variable:2'].x, 0); + assert.ok(p['variable:1'].y < p['variable:2'].y); + assert.ok(p.app.x > CARD_WIDTH + ZONE_PADDING * 2); + const [variables, resources] = zoneBoxes(g, p); + assert.ok(variables.x + variables.width < resources.x, 'zones do not overlap'); +}); + +test('variables view: links only go from an existing shared variable to a resource', () => { + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('variable:2', 'variable', 'sources'), + node('missing:OLD', 'variable', 'sources', { missing: true }), + node('app', 'application', 'resources'), + ], + }; + + assert.equal(canConnect(g, 'variable:1', 'app'), true); + assert.equal(canConnect(g, 'app', 'variable:1'), false); + assert.equal(canConnect(g, 'variable:1', 'variable:2'), false); + assert.equal(canConnect(g, 'missing:OLD', 'app'), false); + assert.equal(canConnect({ nodes: g.nodes }, 'app', 'variable:1'), true, 'other canvases link any two cards'); +}); + +test('variables view: filter resources by project and environment, keep every variable', () => { + const shop = { uuid: 'p1', name: 'Shop' }; + const blog = { uuid: 'p2', name: 'Blog' }; + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('a', 'application', 'resources', { project: shop, environment: { uuid: 'e1', name: 'production' } }), + node('b', 'application', 'resources', { project: shop, environment: { uuid: 'e2', name: 'staging' } }), + node('c', 'application', 'resources', { project: blog, environment: { uuid: 'e3', name: 'production' } }), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'a' }, { id: 'l2', source: 'variable:1', target: 'c' }], + }; + + assert.deepEqual(filterOptions(g).map(p => [p.name, p.environments.map(e => e.name)]), [['Blog', ['production']], ['Shop', ['production', 'staging']]]); + assert.equal(filterGraph(g, {}), g); + const shopOnly = filterGraph(g, { project: 'p1' }); + assert.deepEqual(shopOnly.nodes.map(n => n.id), ['variable:1', 'a', 'b']); + assert.deepEqual(shopOnly.links.map(l => l.id), ['l1']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).nodes.map(n => n.id), ['variable:1', 'b']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).links, []); +}); + +test('variables view: resources that use a variable get their own zone, in one column', () => { + const ctx = { context: 'Shop / prod' }; + const g = groupByUse({ + layout: 'connect', + zones: [{ id: 'sources', icon: 'variable' }, { id: 'connected', icon: 'link' }, { id: 'resources', icon: 'service' }], + nodes: [ + node('variable:1', 'variable', 'sources'), + node('app', 'application', 'resources', ctx), + node('db', 'database', 'resources', ctx), + node('idle', 'application', 'resources', ctx), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'app' }, { id: 'l2', source: 'variable:1', target: 'db' }], + }); + assert.deepEqual(g.nodes.map(n => n.zone), ['sources', 'connected', 'connected', 'resources']); + + const p = layoutGraph(g); + assert.equal(p.app.x, p.db.x, 'connected cards share one column, so no link runs behind a card'); + assert.ok(p.db.y > p.app.y); + const boxes = zoneBoxes(g, p); + const connected = boxes.find(b => b.id === 'connected'); + const others = boxes.find(b => b.id === 'resources'); + assert.ok(connected.y + connected.height < others.y, 'other resources sit below the connected zone'); +}); + +test('readView restores a saved zoom and pan, and ignores bad data', () => { + const store = values => ({ getItem: key => values[key] ?? null }); + assert.deepEqual(readView('k', store({ k: '{"x":-120.5,"y":40,"z":0.6}' })), { x: -120.5, y: 40, z: 0.6 }); + assert.equal(readView('k', store({})), null); + assert.equal(readView('k', store({ k: 'not json' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2}' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2,"z":9}' })), null); +}); + +test('server view: the unmanaged containers group comes first in its zone', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [ + node('a', 'application', 'z1', { context: 'Alpha / prod' }), + node('container:1', 'container', 'z1', { context: 'Unmanaged containers' }), + ], + links: [], + positions: {}, + }; + const positions = layoutServerView(g); + assert.ok(positions['container:1'].x < positions.a.x); +}); + +const apart = (a, b) => a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y; +// Drag card `id` to the top-left of `onto`, as the canvas does: stop at the nearest free spot. +const dragOnto = (g, positions, id, onto) => { + const { others, fits } = dragRules(g, positions, {}, id); + const card = g.nodes.find(n => n.id === id); + const start = { ...positions[id], w: CARD_WIDTH, h: estimateNodeHeight(card) }; + assert.deepEqual(freeSpot(start, others, undefined, fits), start, 'the laid out card is already free, so it does not jump'); + const spot = freeSpot({ ...start, ...positions[onto] }, others, undefined, fits); + return { ...positions, [id]: { x: spot.x, y: spot.y } }; +}; + +test('a dragged card stops before it covers a card or another zone', () => { + const g = graph([node('a', 'application', 's1'), node('b', 'database', 's1'), node('c', 'application', 's2')]); + const before = layoutGraph(g); + for (const [id, onto] of [['a', 'b'], ['a', 'c'], ['c', 'a']]) { + const positions = dragOnto(g, before, id, onto); + const rects = Object.fromEntries(g.nodes.map(n => [n.id, ({ ...positions[n.id], width: CARD_WIDTH, height: estimateNodeHeight(n) })])); + for (const n of g.nodes) if (n.id !== id) assert.ok(apart(rects[id], rects[n.id]), `${id} does not cover ${n.id}`); + const [z1, z2] = zoneBoxes(g, positions); + assert.ok(apart(z1, z2), 'the zones do not overlap'); + } +}); + +test('server view: a dragged card stops before its group covers another group', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [], + positions: {}, + }; + const positions = dragOnto(g, layoutGraph(g), 'a', 'c'); + const [f1, f2] = groupFrames(g.nodes, positions); + assert.ok(apart(f1, f2), 'the project / environment frames do not overlap'); +}); diff --git a/resources/views/components/canvas-view-toggle.blade.php b/resources/views/components/canvas-view-toggle.blade.php new file mode 100644 index 0000000000..d3db4e10d0 --- /dev/null +++ b/resources/views/components/canvas-view-toggle.blade.php @@ -0,0 +1,51 @@ +@props(['listUrl', 'canvasUrl', 'onCanvas' => false, 'views' => null]) +@php + $item = 'flex size-7.5 items-center justify-center rounded-md transition-colors'; + $idle = 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'; + $canvasItem = 'relative flex h-7.5 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium transition-colors'; + $views ??= ['table' => ['Table view', 'unordered-list'], 'grid' => ['Grid view', 'grid']]; + // One list view (no table / grid choice): the list page shows it selected. + $singleList = count($views) === 1; +@endphp +{{-- Table / grid / canvas switcher. On the list page, table and grid switch the + parent's Alpine viewMode; on the canvas page they link back to the list + (?view= makes the list the remembered view again, see RemembersCanvasView). + Pages with one list view pass :views="['list' => [...]]". --}} +
merge(['class' => 'view-toggle']) }}> + @foreach ($views as $mode => [$label, $icon]) + @if ($onCanvas) + + + + @elseif ($singleList) + + + + @else + + @endif + @endforeach + @if ($onCanvas) + + + Canvas + + @else + {{-- A label and a purple "Try it" badge, so people notice the canvas view. --}} + + + Canvas + + Try it + + + @endif +
diff --git a/resources/views/components/dashboard/navbar.blade.php b/resources/views/components/dashboard/navbar.blade.php index db6242f56a..8e7e390b81 100644 --- a/resources/views/components/dashboard/navbar.blade.php +++ b/resources/views/components/dashboard/navbar.blade.php @@ -109,7 +109,7 @@ @if ($showNav)