feat(canvas): add interactive resource views and team avatars (#12160)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-10-14 09:28:10 +02:00
1 parent 448dc8ea0a
commit 33ee09fa87
146 files changed
+12076 -367

No files matched your search

+1 -1
View File
@@ -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 <name> || true` before `docker run --name <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.
@@ -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.']);
}
@@ -0,0 +1,264 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Environment;
use App\Models\Project;
use App\Models\ResourceLink;
use App\Services\EnvironmentCanvas;
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
/**
* Environment canvas: a view of one environment's resources with saved card positions and visual-only links.
*/
class EnvironmentCanvasController extends Controller
{
private const MAX_POSITIONS = 1000;
public function __construct(private EnvironmentCanvas $canvas) {}
public function show(Request $request): JsonResponse
{
$environment = $this->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<int, string> $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;
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Project;
use App\Services\ProjectCanvas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Project canvas: the resources of every environment of one project (or of every project).
* Moves and links are saved through EnvironmentCanvasController. Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
*/
class ProjectCanvasController extends Controller
{
public function __construct(private ProjectCanvas $canvas) {}
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
return response()->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'));
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Services\ServerCanvas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Server canvas: every server of the team (or one server) with the resources on it.
* Moves and links are saved through EnvironmentCanvasController. Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
*/
class ServerCanvasController extends Controller
{
public function __construct(private ServerCanvas $canvas) {}
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->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);
}
}
@@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Services\SharedVariableCanvas;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
/**
* Shared variables canvas: shows which resources use the shared variables of a scope, and adds
* a shared variable to a resource (a resource variable that holds {{scope.KEY}}) or removes it.
* Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
*/
class SharedVariableCanvasController extends Controller
{
use EnvironmentVariableProtection;
public function __construct(private SharedVariableCanvas $canvas) {}
public function show(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 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');
}
}
@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use App\Services\TagCanvas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
/**
* Tags canvas: shows which resources have which tag, and tags or untags a resource.
* Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
*/
class TagCanvasController extends Controller
{
public function __construct(private TagCanvas $canvas) {}
public function show(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->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();
}
}
@@ -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, [
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers;
use App\Services\TeamAvatarStorageService;
use Illuminate\Http\Response;
class TeamAvatarController extends Controller
{
public function __invoke(int $team_id, TeamAvatarStorageService $avatarStorage): Response
{
$team = auth()->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',
]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
/**
* Lets the deploy canvas call API controllers with the browser session.
*
* Gives the session user an in-memory (never saved) API token for the team of
* the canvas page (the X-Canvas-Team header, set when the page was opened), not
* the session team, so switching teams in another tab does not change an open
* canvas. The token has the abilities an API token of that user may have:
* admins and owners get read, write, deploy and read:sensitive, members get
* read only. The usual API middleware (api.token.team rejects teams the user is
* not a member of, api.ability, api.sensitive) and controllers then enforce
* team scope and permissions as for a real token.
*/
class UseSessionTeamApiToken
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->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);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Environment;
use App\Support\ValidationPatterns;
/**
* "New environment" modal of the project page and the project canvas.
* The component must have a $project property.
*/
trait CreatesEnvironments
{
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 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);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Tag;
use Illuminate\Validation\ValidationException;
/**
* "New tag" modal of the tags list and the tags canvas.
*/
trait CreatesTags
{
public string $newTagName = '';
public function createTag()
{
try {
$this->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);
}
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Livewire\Concerns;
use Illuminate\Support\Facades\Cookie;
use Livewire\Attributes\Renderless;
/**
* Remembers the last view (table, grid, list, or canvas) of a list page per user,
* so the page opens the canvas again when the user left it on the canvas.
* One cookie per user and page holds the choice; the server reads it before
* rendering, so the page does not flash the list before it opens the canvas.
*/
trait RemembersCanvasView
{
private const array CANVAS_VIEW_PAGES = ['servers', 'server_resources', 'projects', 'project', 'environment', 'tags', 'team_variables', 'project_variables', 'environment_variables', 'server_variables'];
private const array LIST_VIEWS = ['table', 'grid', 'list'];
/**
* List page switcher: remember table or grid without a re-render.
*/
#[Renderless]
public function saveListView(string $page, string $view): void
{
if (in_array($page, self::CANVAS_VIEW_PAGES, true) && in_array($view, self::LIST_VIEWS, true)) {
$this->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';
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Livewire;
use App\Models\Project;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Full-screen deploy canvas, opened from an environment's New resource page. The
* page is a shell: resources/js/canvas/deploy (Svelte) renders the canvas and calls the
* API controllers at /canvas/api with the browser session, so the API enforces
* team scope and token permissions.
*/
class DeployCanvas extends Component
{
/** The team that owned the page at mount; the canvas API calls use it, not the session team. */
#[Locked]
public int $teamId;
public function mount(): void
{
$this->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<string, string>
*/
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<string, array{0: string, 1: string}>
*/
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<array{0: string, 1: string}>
*/
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();
}
}
+20
View File
@@ -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'])) {
@@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Project;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Environment;
use App\Models\Project;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Full-screen canvas of one environment's deployed resources. The page is a shell:
* resources/js/environment-canvas.js renders the canvas and calls
* Api\EnvironmentCanvasController at /canvas/api with the browser session.
*/
class EnvironmentCanvas extends Component
{
use AuthorizesRequests;
use RemembersCanvasView;
#[Locked]
public Project $project;
#[Locked]
public Environment $environment;
public function mount(string $project_uuid, string $environment_uuid): void
{
$this->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]);
}
}
+9
View File
@@ -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()
+19 -19
View File
@@ -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,
],
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Livewire\Project;
use App\Livewire\Concerns\CreatesEnvironments;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Project;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Read-only canvas of every environment of a project. The page is a shell:
* resources/js/environment-canvas.js renders it from Api\ProjectCanvasController
* at /canvas/api with the browser session.
*/
class ProjectCanvas extends Component
{
use AuthorizesRequests;
use CreatesEnvironments;
use RemembersCanvasView;
#[Locked]
public Project $project;
public function mount(string $project_uuid): void
{
$this->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]);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Project;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Project;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Read-only canvas of every project of the team. The page is a shell:
* resources/js/environment-canvas.js renders it from Api\ProjectCanvasController
* at /canvas/api with the browser session.
*/
class ProjectsCanvas extends Component
{
use AuthorizesRequests;
use RemembersCanvasView;
/** The team that owned the page at mount; the canvas API calls use it, not the session team. */
#[Locked]
public int $teamId;
public function mount(): void
{
$this->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]);
}
}
+11
View File
@@ -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('<x-resource-logo :resource="$resource" />', ['resource' => $item]),
'destination' => [
'server' => [
'name' => $item->destination?->server?->name ?? 'Unknown',
@@ -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
{
+2 -3
View File
@@ -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);
}
+8 -38
View File
@@ -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', [
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Server;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Server;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Read-only canvas of all servers (or one server) with their resources.
* The page is a shell: resources/js/environment-canvas.js renders it from
* Api\ServerCanvasController at /canvas/api with the browser session.
*/
class Canvas extends Component
{
use AuthorizesRequests;
use RemembersCanvasView;
#[Locked]
public ?Server $server = null;
/** The team that owned the page at mount; the canvas API calls use it, not the session team. */
#[Locked]
public int $teamId;
public function mount(?string $server_uuid = null): void
{
if ($server_uuid !== null) {
$this->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]);
}
}
@@ -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'
<div class="rounded-xl border border-neutral-200 bg-white p-4 text-[12px] text-neutral-500 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
Loading registries...
</div>
HTML;
return view('livewire.server.docker-registries.server-registries-placeholder');
}
public function render()
+10 -1
View File
@@ -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();
}
+5
View File
@@ -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);
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Livewire\SharedVariables;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Services\SharedVariableCanvas;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Canvas of the shared variables of one scope and the resources that use them.
* The page is a shell: resources/js/environment-canvas.js renders it from
* Api\SharedVariableCanvasController at /canvas/api with the browser session.
*/
class Canvas extends Component
{
use AuthorizesRequests;
use RemembersCanvasView;
#[Locked]
public string $scope = 'team';
#[Locked]
public string $endpoint = '';
#[Locked]
public string $listUrl = '';
/** The team that owned the page at mount; later requests use it, not the session team. */
#[Locked]
public int $teamId;
#[Locked]
public ?string $projectUuid = null;
#[Locked]
public ?string $environmentUuid = null;
#[Locked]
public ?string $serverUuid = null;
public function mount(SharedVariableCanvas $canvas, ?string $project_uuid = null, ?string $environment_uuid = null, ?string $server_uuid = null): void
{
$this->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]);
}
}
@@ -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();
}
@@ -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();
}
@@ -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();
}
@@ -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();
}
+2
View File
@@ -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;
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Tags;
use App\Livewire\Concerns\CreatesTags;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Tag;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Full-screen canvas of the team's tags and the resources that have them.
* The page is a shell: resources/js/environment-canvas.js renders it from
* Api\TagCanvasController at /canvas/api with the browser session.
*/
class Canvas extends Component
{
use AuthorizesRequests;
use CreatesTags;
use RemembersCanvasView;
/** The team that owned the page at mount; the canvas API calls use it, not the session team. */
#[Locked]
public int $teamId;
public function mount(): void
{
$this->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]);
}
}
+27
View File
@@ -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', [
+39
View File
@@ -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');
+18
View File
@@ -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();
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* A visual-only link between two resources of one environment on the environment canvas.
*/
class ResourceLink extends BaseModel
{
protected $fillable = [
'environment_id',
'source_type',
'source_id',
'target_type',
'target_id',
'label',
'created_by',
];
public function environment(): BelongsTo
{
return $this->belongsTo(Environment::class);
}
public function source(): MorphTo
{
return $this->morphTo();
}
public function target(): MorphTo
{
return $this->morphTo();
}
}
+9
View File
@@ -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;
-8
View File
@@ -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');
+5
View File
@@ -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)
+380
View File
@@ -0,0 +1,380 @@
<?php
namespace App\Services;
use App\Models\Application;
use App\Models\Environment;
use App\Models\ResourceLink;
use App\Models\Server;
use App\Models\Service;
use App\Support\ResourceStartActivity;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Spatie\Activitylog\Models\Activity;
/**
* Builds the environment canvas graph: server zones, resource cards, manual links and saved positions.
*
* Cards hold summary fields only. Never add environment variables, passwords, keys or connection URLs.
*/
class EnvironmentCanvas
{
public const MAX_COORDINATE = 100000;
/**
* Environment relations that hold standalone databases, keyed by relation name.
*/
public const DATABASE_RELATIONS = [
'postgresqls',
'redis',
'mongodbs',
'mysqls',
'mariadbs',
'keydbs',
'dragonflies',
'clickhouses',
'sqlites',
];
private const DATABASE_ENGINE_NAMES = [
'postgresql' => 'PostgreSQL',
'redis' => 'Redis',
'mongodb' => 'MongoDB',
'mysql' => 'MySQL',
'mariadb' => 'MariaDB',
'keydb' => 'KeyDB',
'dragonfly' => 'Dragonfly',
'clickhouse' => 'ClickHouse',
'sqlite' => 'SQLite',
];
private const BUILD_PACK_NAMES = [
'nixpacks' => 'Nixpacks',
'railpack' => 'Railpack',
'static' => 'Static',
'dockerfile' => 'Dockerfile',
'dockercompose' => 'Docker Compose',
'dockerimage' => 'Docker Image',
];
/**
* The canvas payload of an environment.
*
* @return array{project: array{uuid: string, name: string, link: string}, environment: array{uuid: string, name: string, link: string}, can_edit: bool, zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array{id: string, source: string, target: string, label: ?string}>, positions: array<string, array{x: int, y: int}>}
*/
public function payload(Environment $environment, bool $canReadSensitive, bool $canEdit): array
{
$environment->loadMissing('project');
$resources = $this->loadResources($environment);
$graph = $this->graph($resources, $environment->resourceLinks()->orderBy('id')->get(), $canReadSensitive);
return [
'project' => [
'uuid' => $environment->project->uuid,
'name' => $environment->project->name,
'link' => route('project.show', ['project_uuid' => $environment->project->uuid]),
],
'environment' => [
'uuid' => $environment->uuid,
'name' => $environment->name,
'link' => route('project.resource.index', ['project_uuid' => $environment->project->uuid, 'environment_uuid' => $environment->uuid]),
],
'can_edit' => $canEdit,
...$graph,
'positions' => $this->filterPositions($environment->canvas_positions ?? [], $resources->keys()->all()),
];
}
/**
* Zones (servers), cards and links of the given resources. Links with an end outside the resources are left out.
*
* @param Collection<string, Model> $resources keyed by uuid
* @param Collection<int, ResourceLink> $links
* @return array{zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array{id: string, source: string, target: string, label: ?string}>}
*/
public function graph(Collection $resources, Collection $links, bool $canReadSensitive): array
{
$startingServiceUuids = $this->startingServiceUuids($resources->filter(fn (Model $resource) => $resource instanceof Service));
$servers = collect();
$nodes = $resources->map(function (Model $resource) use (&$servers, $startingServiceUuids) {
$node = match (true) {
$resource instanceof Application => $this->applicationNode($resource),
$resource instanceof Service => $this->serviceNode($resource, in_array($resource->uuid, $startingServiceUuids, true)),
default => $this->databaseNode($resource),
};
foreach ($node['servers'] as $server) {
$servers->put($server->id, $server);
}
unset($node['servers']);
return $node;
})->values();
$uuidByMorphKey = $resources->mapWithKeys(fn (Model $resource) => [$this->morphKey($resource->getMorphClass(), $resource->getKey()) => $resource->uuid]);
$links = $links
->map(fn (ResourceLink $link) => [
'link' => $link,
'source' => $uuidByMorphKey->get($this->morphKey($link->source_type, $link->source_id)),
'target' => $uuidByMorphKey->get($this->morphKey($link->target_type, $link->target_id)),
])
->filter(fn (array $link) => $link['source'] !== null && $link['target'] !== null)
->map(fn (array $link) => $this->linkPayload($link['link'], $link['source'], $link['target']))
->values();
return [
'zones' => $servers->values()->map(fn (Server $server) => $this->zone($server, $canReadSensitive))->all(),
'nodes' => $nodes->all(),
'links' => $links->all(),
];
}
/**
* @return array{id: string, name: string, ip: ?string, is_reachable: bool, is_usable: bool, link: string}
*/
public function zone(Server $server, bool $canReadSensitive): array
{
return [
'id' => $server->uuid,
'name' => $server->name,
'ip' => $canReadSensitive ? $server->ip : null,
'is_reachable' => (bool) $server->settings?->is_reachable,
'is_usable' => (bool) $server->settings?->is_usable,
'link' => route('server.show', ['server_uuid' => $server->uuid]),
];
}
/**
* Top-level resources of the environment (applications, services, standalone databases), keyed by uuid,
* with the relations the canvas reads.
*
* @return Collection<string, Model>
*/
public function loadResources(Environment $environment): Collection
{
$project = $environment->project;
$environment->setRelation('project', $project);
$applications = $environment->applications()
->with(['destination.server.settings', 'additional_servers.settings', 'persistentStorages'])
->get();
$services = $environment->services()
->with([
'server.settings',
'destination.server.settings',
'applications.persistentStorages',
'databases.persistentStorages',
])
->get();
$databases = collect(self::DATABASE_RELATIONS)
->flatMap(fn (string $relation) => $environment->{$relation}()
->with(['destination.server.settings', 'persistentStorages'])
->get());
return collect()
->concat($applications)
->concat($services)
->concat($databases)
->each(fn (Model $resource) => $resource->setRelation('environment', $environment))
->keyBy('uuid');
}
/**
* Morph type and id of every top-level resource of the environment, keyed by uuid. Reads ids only.
*
* @return Collection<string, array{type: string, id: int}>
*/
public function resourceIndex(Environment $environment): Collection
{
return collect(['applications', 'services', ...self::DATABASE_RELATIONS])
->flatMap(function (string $relation) use ($environment) {
$query = $environment->{$relation}();
$type = $query->getRelated()->getMorphClass();
return $query->toBase()->get(['id', 'uuid'])
->map(fn (object $row) => ['uuid' => $row->uuid, 'type' => $type, 'id' => (int) $row->id]);
})
->keyBy('uuid')
->map(fn (array $row) => ['type' => $row['type'], 'id' => $row['id']]);
}
/**
* Keep only positions of the given node uuids, as integer coordinates.
*
* @param array<string, mixed> $positions
* @param array<int, string> $nodeUuids
* @return array<string, array{x: int, y: int}>
*/
public function filterPositions(array $positions, array $nodeUuids): array
{
return collect($positions)
->only($nodeUuids)
->filter(fn ($position) => is_array($position) && is_numeric($position['x'] ?? null) && is_numeric($position['y'] ?? null))
->map(fn (array $position) => ['x' => (int) $position['x'], 'y' => (int) $position['y']])
->all();
}
/**
* @return array{id: string, source: string, target: string, label: ?string}
*/
public function linkPayload(ResourceLink $link, string $sourceUuid, string $targetUuid): array
{
return [
'id' => $link->uuid,
'source' => $sourceUuid,
'target' => $targetUuid,
'label' => $link->label,
];
}
/**
* @return array<string, mixed>
*/
private function applicationNode(Application $application): array
{
$server = $application->destination?->server;
$extraServers = $application->additional_servers->filter()->values();
$buildPack = $application->build_pack;
$detail = $buildPack === 'dockerimage'
? collect([$application->docker_registry_image_name, $application->docker_registry_image_tag])->filter()->implode(':')
: $application->git_branch;
return [
'id' => $application->uuid,
'type' => 'application',
'name' => $application->name,
'status' => (string) $application->status,
'zone' => $server?->uuid,
'extra_zones' => $extraServers->pluck('uuid')->reject(fn ($uuid) => $uuid === $server?->uuid)->unique()->values()->all(),
'urls' => $this->splitUrls($application->fqdn),
'kind' => $buildPack,
'subtitle' => $this->subtitle([self::BUILD_PACK_NAMES[$buildPack] ?? str($buildPack)->headline()->value(), $detail]),
'children' => [],
'volumes' => $application->persistentStorages->pluck('name')->filter()->values()->all(),
'link' => $application->link(),
'logo' => resource_logo($application),
'servers' => collect([$server])->concat($extraServers)->filter()->all(),
];
}
/**
* @return array<string, mixed>
*/
private function serviceNode(Service $service, bool $isStarting): array
{
$server = $service->server ?? $service->destination?->server;
$children = $service->applications
->map(fn ($child) => ['name' => $child->name, 'type' => 'application', 'status' => (string) $child->status])
->concat($service->databases->map(fn ($child) => ['name' => $child->name, 'type' => 'database', 'status' => (string) $child->status]))
->values();
$containerCount = $children->count();
return [
'id' => $service->uuid,
'type' => 'service',
'name' => $service->name,
'status' => $isStarting ? 'starting:unhealthy' : (string) $service->containerStatus(),
'zone' => $server?->uuid,
'extra_zones' => [],
'urls' => $service->applications
->flatMap(fn ($child) => $this->splitUrls($child->fqdn))
->unique()
->values()
->all(),
'kind' => null,
'subtitle' => $this->subtitle(['Service', $containerCount.' '.str('container')->plural($containerCount)]),
'children' => $children->all(),
'volumes' => $service->applications->concat($service->databases)
->flatMap(fn ($child) => $child->persistentStorages->pluck('name'))
->filter()
->unique()
->values()
->all(),
'link' => $service->link(),
'logo' => resource_logo($service),
'servers' => collect([$server])->filter()->all(),
];
}
/**
* @return array<string, mixed>
*/
private function databaseNode(Model $database): array
{
$server = $database->destination?->server;
$engine = str($database->type())->after('standalone-')->value();
return [
'id' => $database->uuid,
'type' => 'database',
'name' => $database->name,
'status' => (string) $database->status,
'zone' => $server?->uuid,
'extra_zones' => [],
'urls' => [],
'kind' => $engine,
'subtitle' => $this->subtitle([self::DATABASE_ENGINE_NAMES[$engine] ?? str($engine)->headline()->value(), $database->image]),
'children' => [],
'volumes' => $database->persistentStorages->pluck('name')->filter()->values()->all(),
'link' => $database->link(),
'logo' => resource_logo($database),
'servers' => collect([$server])->filter()->all(),
];
}
/**
* Uuids of services with a queued or running start, read with one query instead of one per service.
*
* @param Collection<string, Service> $services
* @return array<int, string>
*/
private function startingServiceUuids(Collection $services): array
{
if ($services->isEmpty()) {
return [];
}
$staleAfterSeconds = max(ResourceStartActivity::QUEUED_STALE_AFTER_SECONDS, ResourceStartActivity::IN_PROGRESS_STALE_AFTER_SECONDS);
/** @var EloquentCollection<int, Activity> $activities */
$activities = Activity::query()
->whereIn('properties->type_uuid', $services->pluck('uuid')->all())
->where('updated_at', '>=', now()->subSeconds($staleAfterSeconds))
->latest()
->orderByDesc('id')
->get();
return $activities
->groupBy(fn (Activity $activity) => (string) data_get($activity, 'properties.type_uuid'))
->filter(fn (EloquentCollection $group) => ResourceStartActivity::isRunning($group->first()))
->keys()
->all();
}
/**
* @return array<int, string>
*/
private function splitUrls(?string $fqdn): array
{
return str((string) $fqdn)->explode(',')
->map(fn (string $url) => trim($url))
->filter()
->unique()
->values()
->all();
}
/**
* @param array<int, ?string> $parts
*/
private function subtitle(array $parts): string
{
return collect($parts)->map(fn ($part) => trim((string) $part))->filter()->implode(' · ');
}
private function morphKey(string $type, int|string $id): string
{
return $type.'|'.$id;
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Services;
use App\Models\Environment;
use App\Models\Project;
use App\Models\ResourceLink;
use Illuminate\Database\Eloquent\Model;
/**
* Builds the project canvas: every resource of the project's environments, one zone per server,
* grouped by environment. Cards keep the positions and links saved on their environment canvas;
* admins change them through the environment canvas endpoints of each card.
*/
class ProjectCanvas
{
public function __construct(private EnvironmentCanvas $canvas, private ServerCanvas $servers) {}
/**
* The canvas of every project of a team: one zone per project, cards grouped by environment.
*
* @return array{title: string, title_link: string, can_edit: bool, layout: 'server', zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, positions: array<string, array{x: int, y: int}>}
*/
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<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, positions: array<string, array{x: int, y: int}>}
*/
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],
];
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Services;
use App\Models\Application;
use App\Models\Environment;
use App\Models\ResourceLink;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Builds the server canvas: one zone per server with every resource of the team on it,
* in the same shape as the environment canvas. Cards keep the positions and links saved on their environment canvas;
* admins change them through the environment canvas endpoints of each card.
*/
class ServerCanvas
{
public function __construct(private EnvironmentCanvas $canvas) {}
/**
* @param Collection<int, Server> $servers servers of the team to show
* @return array{title: string, title_link: ?string, can_edit: bool, layout: 'server', zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, positions: array<string, array{x: int, y: int}>}
*/
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<int, array<string, mixed>>
*/
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<int, int> $environmentIds
* @return Collection<string, Model>
*/
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');
}
}
+297
View File
@@ -0,0 +1,297 @@
<?php
namespace App\Services;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Builds the shared variables canvas of one scope (team, project, environment or server):
* the shared variables on the left, the resources that can use them on the right, and a link
* for every resource that references a variable ({{team.KEY}}, ...) in one of its own variables.
*
* Links come from the saved resource variables, so a deleted reference removes the link and a
* reference to a deleted shared variable shows as a missing variable. Values are never sent.
*/
class SharedVariableCanvas
{
public const SCOPES = ['team', 'project', 'environment', 'server'];
public const SOURCES_ZONE = 'sources';
public const CONNECTED_ZONE = 'connected';
public const RESOURCES_ZONE = 'resources';
public function __construct(private EnvironmentCanvas $canvas, private ServerCanvas $serverCanvas) {}
/**
* @return array{title: string, title_link: string, can_edit: bool, can_move: false, layout: 'connect', scope: string, zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, 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<string, Model> $resources keyed by uuid
* @return Collection<int, array<string, mixed>>
*/
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<int, SharedEnvironmentVariable>
*/
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<string, Model>
*/
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<int, SharedEnvironmentVariable> $variables
* @param Collection<string, Model> $resources
* @return array<int, array{id: string, source: string, target: string, key: string, keys: array<int, string>, 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<string, Model> $resources
* @return Collection<int, EnvironmentVariable>
*/
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<int, string>
*/
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]),
};
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Services;
use App\Models\Environment;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Builds the tags canvas of a team: the tags on the left, the resources on the right, and a link
* for every tag on a resource. Same layout and wording slots as the shared variables canvas.
*/
class TagCanvas
{
public function __construct(private SharedVariableCanvas $connect, private ServerCanvas $serverCanvas) {}
/**
* @return array{title: string, title_link: string, can_edit: bool, can_move: false, layout: 'connect', zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, 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<int, Tag>
*/
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<string, Model>
*/
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<int, Tag> $tags
* @param Collection<string, Model> $resources
* @return array<int, array{id: string, source: string, target: string, key: string, keys: array{}, label: null, removable: true, missing: false}>
*/
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));
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Services;
use App\Models\Team;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class TeamAvatarStorageService extends AvatarStorageService
{
public function storeTeam(Team $team, UploadedFile $upload): void
{
$settings = instanceSettings();
$storageType = $settings->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) {
}
}
}
+96
View File
@@ -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+/";
+23
View File
@@ -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);
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('environments', function (Blueprint $table) {
$table->json('canvas_positions')->nullable();
});
}
public function down(): void
{
Schema::table('environments', function (Blueprint $table) {
$table->dropColumn('canvas_positions');
});
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('resource_links', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('teams', function (Blueprint $table) {
$table->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']);
});
}
};
+219 -10
View File
@@ -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"
}
}
}
+2
View File
@@ -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"
+3 -8
View File
@@ -1,10 +1,5 @@
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" rx="55.2101" fill="#099CEC" fill-opacity="0.16"/>
<g transform="translate(100,100) scale(0.72) translate(-100,-100)"><rect x="18" y="20" width="167.171" height="160.057" fill="url(#pattern0_12315_226)"/></g>
<defs>
<pattern id="pattern0_12315_226" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_12315_226" transform="translate(0 -0.0166667) scale(0.00403646)"/>
</pattern>
<image id="image0_12315_226" width="256" height="256" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAACi1BMVEUAAAAInOwJm+wHnegPlvAIm+wJm+sJm+sGm+wA//8Aqv8MneYJnOsInOsNofEAn98Af/8InOsAqv8AkdoJm+wHmu8Jm+wPn+8Km+sKnOoInOsJnOwLnu4Im+wJnO0Im+0Inu0AlOkIme0InOsKnOoJnOwIm+sInOwJm+sKnekJnOwGmesInOwJnOsJm+wJm+wJnOwJm+sJnOsAquIIm+wJm+sJm+wIm+sInOsJm+wIm+sGnuoMmfILnO0Jm+sKmeoHnOwJnOsJnOwJnOwKm+oInOsLmu0Im+wInOsInOsIm+sJnOsJmusInOwInOoJm+wIm+wIm+wJnOsInOsJm+sJnOsJnOwHnOwJmuwJm+wHmusJm+sHmekJnOsIme4Im+wJnewIm+wIneoKmukHm+wJnOsJnOwHn+8InOsInOwJm+wIm+sIm+wJm+sInOwJnOsJmuwInOsJnOsIm+wJm+sLm+gLlucJoOwJnOsJnOsJm+sJnOwIm+wKmu0GmuoJm+sInewInOsJm+sLm+4Im+sJnOsJmesIm+sImuoIm+sKm+sJm+wHm+kIm+sJm+sInOwIm+sInOwJnOsInO4JnOsIne4Km+0Rme4Jm+wAnOsInOwHm+wJnOsIm+sKnuoInOwInO0GnewJm+sIm+wOm/AInOsJnOsInOwKne0JmuwJnOwJm+wIm+sIm+wHnOsAmf8Jm+wJnOwHnOwInO0JnOwJnOwInOsJm+sAmeUInOsHnewInOsHm+sIm+sJm+wJnewInOsKm+0Ao+wInOwAf/8JnOwAoucJnOsJnOwInOwJm+sJm+sGnOsJm+sInOwJm+wInOwJm+wJm+sInOwJm+wHnewJnOsJnOwJm+zwClkZAAAA2XRSTlMAejYiEedNgykBAxX6dxMIAvAGB6Qh9hAzS5H4LbFVVx0MOl0xidDogC/zKMz9wPKMqfsJtJxu5HTYriUULJ0ZYNbCo0qbK+qOW8hOa5Y+il95ULjGhKZiVL5CaSPEHpdwmj8wRWjcIMm1Uu97oX+GOOOezWcXFhsagfFvfkcmu17H3y5zNDWsPdNm3SRahc+2fLofxTxkD/cNmWGq7DKzOSrgzhLR4cpJHPW8duVDBaK/RnJtU63DCtRE60CS9FHuSA7pBL0LoNeU/vkn/ObamHGfsMFj3v+IzzwotQAABUNJREFUeNrt2OVzFEkcxvEHQkJ24w4kuLu7u567u7u7K+7uboHgBELcZS/kEnjy5xxXV9TdhZmwMrM73fP7vH2q33yrq7qqIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIK3Q29hjcgsZaJIAEkAASQAJIAAkgASSABJAAEkACSAAJIAEkgASQABJASzHG+sh/uRBK2X7D2O8AUk22qwBgss2DYqJo7E8AnWisub1XQAJIAAkgASSABAhM4vkKdwd4lAP6uTlADMn41lTXBjgUzb8NG+rWAOn8x8gYdwboytt8TW4MkH+T/6r1uC/A1/yvGcluC9Aphf+T7nVZgEFs4x53BUgdyLYmuSrAYt5h+JtuCvAQ7/TWa+4JEDeGBuZ4XRNgFg3lInzSOhrLAOA12aYCgMk2FIEYTUNDZsIdPAU0NgPusIomhsfCFVbTzHVYKbGDsVIAWSZbVwBJJttFAGgy1gMALhif+xhtrKGZzGmwUKxZZrtegT9oqBFt/EZTz7siwDCaanFFgIk05YtzQ4B6mlvsggAVbMciFwSoYjtGAEDvXjldbmmY5tEyQG+2Y+So5pp63lZXvuSdt7tmaRbAywANH9B/sE4BkMnAXdnp1SfATQbjylVtAlxmUOp2bNckQDWD9ONpPQI0MVh1hVoEuMagVWsRICmTQfJ9q0UAnAz+AugR4AKD42vQJEBcPINyBpoEwKUgL4A2AToyGLUIzNM3jXUG0Mtk6w+gzGS7DgDRxqYAwA/G5ypxp7MMXMEh6KOfjwHbAo2kzmGgunugja25pxioDxKhiay+zdsYsBPvQwfHj31xsp5BiD+Gu4prdbTr1evWfskg1ffE3b1MbQ0phR9eoa4mD4Y/RlFT86vgl9HUUs05+KmSGpo9ywt/HaZu4ptjkuG/U9TK7l074xCQEurBF/3w3EGrGzwIVBEVV9IQFRWVmIRg+ai4VoSmjmob8yxCk021jUaIUqi2fQjRQirtLEL1KpV2DqF6nSo76EGo3qDKihGyM1TYMA9CtoYKK0XotlBdl2GB01TXZlggg8qaAiskD6Gi6qfCEkuoqFZY4wWqqbwM1lhGNfWFRfpRSSM8sIg3hQrKHgrLbKCC1sM666mej/JgnV+pnOwcWCi5nKpJgKXSqZhNXliqJ9VSshHWyi+iUophtV1USS0sd5EKOZoEy6UtpDLKj8MGi6iKMddgh1iqYgLs0UI1FMImD1IJ47ywSXINFXA0H7a5Qedr/AT2STtCpztwHICLr0B5A2xVMZmONnA6bPY4nSw6FrZ7gs4VvR/2WxBPp1q5CuFwHx1q+WcIi94r6UhntyJMNvvoQOlpCJv+dJy6QR6ET9paOkxRMcJqYwEdpfEnhNlVHx2kexbCrgMdo77JgwiopUPU7EFEeC/REc5UIULyLzPyjpQicqoqGWF1O8oAFxeYfS8ibGwzI2fkpDxEnLcbI6Ru7kw4wlc+RkLlL3CKa9EMu8PH4CBx3zG8ln/vhaNUPJDN8DlY7IHj7D/KMHkmxgMnqjifQvtlzl8Ax0rs5qO9ahIS4Wh79tI+KeP7wPk6rqMt6rv3SIIaGrqdoMW27V3xKRQS16GR1jn14cUqKKfPcyW0wImTuYOhqLzThQcYit17c/flQW05Ey4VMAglI0b1jIIePNPvL9xURH9NbBl//tzn0E1yxjcXascNGEgT8dFXNlQ/smxpzljoLbXXSy8u7bvi54SEd99LuOWpGz3mPdklowxCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhAifvwDAua/EpaY9sQAAAABJRU5ErkJggg=="/>
</defs>
<g transform="translate(54,26) scale(3.8333)"><path fill="#099CEC" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></g>
<path d="M58 122L100 140L142 122M58 144L100 162L142 144" stroke="#099CEC" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+2 -9
View File
@@ -1,11 +1,4 @@
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" rx="55.2101" fill="#099CEC" fill-opacity="0.16"/>
<g transform="translate(100,100) scale(0.72) translate(-100,-100)"><path d="M32.0004 148.667C32 148.143 32 147.603 32 147.046V52.9571C32 43.8718 32 39.3258 33.6371 35.8557C35.0772 32.8033 37.3734 30.3234 40.1997 28.7681C43.4128 27 47.6221 27 56.0344 27H143.154C151.566 27 155.77 27 158.983 28.7681C161.809 30.3234 164.11 32.8033 165.55 35.8557C167.185 39.3224 167.185 43.8629 167.185 52.9305V147.069C167.185 151.003 167.185 154.084 167.052 156.594M32.0004 148.667C32.0062 156.684 32.1004 160.88 33.6371 164.138C35.0772 167.19 37.3734 169.678 40.1997 171.234C43.4096 173 47.6138 173 56.0097 173H143.179C151.575 173 155.773 173 158.983 171.234C161.809 169.678 164.11 167.19 165.55 164.138C166.476 162.175 166.877 159.87 167.052 156.594M32.0004 148.667L67.8089 103.549L67.8195 103.536C70.9954 99.5344 72.5861 97.5302 74.4727 96.8078C76.13 96.1733 77.9394 96.1881 79.5884 96.8474C81.4675 97.5988 83.0328 99.6259 86.1636 103.683L106.225 129.683C109.123 133.438 110.58 135.327 112.35 136.085C113.911 136.754 115.632 136.829 117.242 136.316C119.076 135.732 120.69 133.989 123.917 130.504L127.65 126.472C130.936 122.923 132.578 121.15 134.438 120.571C136.071 120.062 137.817 120.171 139.388 120.872C141.177 121.67 142.628 123.623 145.531 127.541L167.052 156.594M167.052 156.594L167.185 156.775" stroke="#099CEC" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="77" y="27" width="81.4667" height="78" fill="url(#pattern0_12315_221)"/></g>
<defs>
<pattern id="pattern0_12315_221" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_12315_221" transform="translate(0 -0.0166667) scale(0.00403646)"/>
</pattern>
<image id="image0_12315_221" width="256" height="256" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAACi1BMVEUAAAAInOwJm+wHnegPlvAIm+wJm+sJm+sGm+wA//8Aqv8MneYJnOsInOsNofEAn98Af/8InOsAqv8AkdoJm+wHmu8Jm+wPn+8Km+sKnOoInOsJnOwLnu4Im+wJnO0Im+0Inu0AlOkIme0InOsKnOoJnOwIm+sInOwJm+sKnekJnOwGmesInOwJnOsJm+wJm+wJnOwJm+sJnOsAquIIm+wJm+sJm+wIm+sInOsJm+wIm+sGnuoMmfILnO0Jm+sKmeoHnOwJnOsJnOwJnOwKm+oInOsLmu0Im+wInOsInOsIm+sJnOsJmusInOwInOoJm+wIm+wIm+wJnOsInOsJm+sJnOsJnOwHnOwJmuwJm+wHmusJm+sHmekJnOsIme4Im+wJnewIm+wIneoKmukHm+wJnOsJnOwHn+8InOsInOwJm+wIm+sIm+wJm+sInOwJnOsJmuwInOsJnOsIm+wJm+sLm+gLlucJoOwJnOsJnOsJm+sJnOwIm+wKmu0GmuoJm+sInewInOsJm+sLm+4Im+sJnOsJmesIm+sImuoIm+sKm+sJm+wHm+kIm+sJm+sInOwIm+sInOwJnOsInO4JnOsIne4Km+0Rme4Jm+wAnOsInOwHm+wJnOsIm+sKnuoInOwInO0GnewJm+sIm+wOm/AInOsJnOsInOwKne0JmuwJnOwJm+wIm+sIm+wHnOsAmf8Jm+wJnOwHnOwInO0JnOwJnOwInOsJm+sAmeUInOsHnewInOsHm+sIm+sJm+wJnewInOsKm+0Ao+wInOwAf/8JnOwAoucJnOsJnOwInOwJm+sJm+sGnOsJm+sInOwJm+wInOwJm+wJm+sInOwJm+wHnewJnOsJnOwJm+zwClkZAAAA2XRSTlMAejYiEedNgykBAxX6dxMIAvAGB6Qh9hAzS5H4LbFVVx0MOl0xidDogC/zKMz9wPKMqfsJtJxu5HTYriUULJ0ZYNbCo0qbK+qOW8hOa5Y+il95ULjGhKZiVL5CaSPEHpdwmj8wRWjcIMm1Uu97oX+GOOOezWcXFhsagfFvfkcmu17H3y5zNDWsPdNm3SRahc+2fLofxTxkD/cNmWGq7DKzOSrgzhLR4cpJHPW8duVDBaK/RnJtU63DCtRE60CS9FHuSA7pBL0LoNeU/vkn/ObamHGfsMFj3v+IzzwotQAABUNJREFUeNrt2OVzFEkcxvEHQkJ24w4kuLu7u567u7u7K+7uboHgBELcZS/kEnjy5xxXV9TdhZmwMrM73fP7vH2q33yrq7qqIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIK3Q29hjcgsZaJIAEkAASQAJIAAkgASSABJAAEkACSAAJIAEkgASQABJASzHG+sh/uRBK2X7D2O8AUk22qwBgss2DYqJo7E8AnWisub1XQAJIAAkgASSABAhM4vkKdwd4lAP6uTlADMn41lTXBjgUzb8NG+rWAOn8x8gYdwboytt8TW4MkH+T/6r1uC/A1/yvGcluC9Aphf+T7nVZgEFs4x53BUgdyLYmuSrAYt5h+JtuCvAQ7/TWa+4JEDeGBuZ4XRNgFg3lInzSOhrLAOA12aYCgMk2FIEYTUNDZsIdPAU0NgPusIomhsfCFVbTzHVYKbGDsVIAWSZbVwBJJttFAGgy1gMALhif+xhtrKGZzGmwUKxZZrtegT9oqBFt/EZTz7siwDCaanFFgIk05YtzQ4B6mlvsggAVbMciFwSoYjtGAEDvXjldbmmY5tEyQG+2Y+So5pp63lZXvuSdt7tmaRbAywANH9B/sE4BkMnAXdnp1SfATQbjylVtAlxmUOp2bNckQDWD9ONpPQI0MVh1hVoEuMagVWsRICmTQfJ9q0UAnAz+AugR4AKD42vQJEBcPINyBpoEwKUgL4A2AToyGLUIzNM3jXUG0Mtk6w+gzGS7DgDRxqYAwA/G5ypxp7MMXMEh6KOfjwHbAo2kzmGgunugja25pxioDxKhiay+zdsYsBPvQwfHj31xsp5BiD+Gu4prdbTr1evWfskg1ffE3b1MbQ0phR9eoa4mD4Y/RlFT86vgl9HUUs05+KmSGpo9ywt/HaZu4ptjkuG/U9TK7l074xCQEurBF/3w3EGrGzwIVBEVV9IQFRWVmIRg+ai4VoSmjmob8yxCk021jUaIUqi2fQjRQirtLEL1KpV2DqF6nSo76EGo3qDKihGyM1TYMA9CtoYKK0XotlBdl2GB01TXZlggg8qaAiskD6Gi6qfCEkuoqFZY4wWqqbwM1lhGNfWFRfpRSSM8sIg3hQrKHgrLbKCC1sM666mej/JgnV+pnOwcWCi5nKpJgKXSqZhNXliqJ9VSshHWyi+iUophtV1USS0sd5EKOZoEy6UtpDLKj8MGi6iKMddgh1iqYgLs0UI1FMImD1IJ47ywSXINFXA0H7a5Qedr/AT2STtCpztwHICLr0B5A2xVMZmONnA6bPY4nSw6FrZ7gs4VvR/2WxBPp1q5CuFwHx1q+WcIi94r6UhntyJMNvvoQOlpCJv+dJy6QR6ET9paOkxRMcJqYwEdpfEnhNlVHx2kexbCrgMdo77JgwiopUPU7EFEeC/REc5UIULyLzPyjpQicqoqGWF1O8oAFxeYfS8ibGwzI2fkpDxEnLcbI6Ru7kw4wlc+RkLlL3CKa9EMu8PH4CBx3zG8ln/vhaNUPJDN8DlY7IHj7D/KMHkmxgMnqjifQvtlzl8Ax0rs5qO9ahIS4Wh79tI+KeP7wPk6rqMt6rv3SIIaGrqdoMW27V3xKRQS16GR1jn14cUqKKfPcyW0wImTuYOhqLzThQcYit17c/flQW05Ey4VMAglI0b1jIIePNPvL9xURH9NbBl//tzn0E1yxjcXascNGEgT8dFXNlQ/smxpzljoLbXXSy8u7bvi54SEd99LuOWpGz3mPdklowxCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhAifvwDAua/EpaY9sQAAAABJRU5ErkJggg=="/>
</defs>
<g transform="translate(40,40) scale(5)"><path fill="#099CEC" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+2 -8
View File
@@ -1,11 +1,5 @@
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" rx="55.2101" fill="#099CEC" fill-opacity="0.16"/>
<g transform="translate(100,100) scale(0.72) translate(-100,-100)"><path d="M109.214 24.0073C108.376 24 107.434 24 106.359 24H67.0872C57.2563 24 52.3373 24 48.5824 25.8318C45.2795 27.4431 42.5961 30.0123 40.9132 33.1746C39 36.7697 39 41.4795 39 50.892V148.369C39 157.782 39 162.486 40.9132 166.081C42.5961 169.243 45.2795 171.817 48.5824 173.428C52.3336 175.258 57.2468 175.258 67.0586 175.258L133.816 175.258C143.627 175.258 148.533 175.258 152.284 173.428C155.587 171.817 158.28 169.243 159.963 166.081C161.874 162.489 161.874 157.792 161.874 148.398V77.1562C161.874 76.1249 161.874 75.2228 161.866 74.4194M109.214 24.0073C111.721 24.0292 113.307 24.1163 114.821 24.4644C116.612 24.8761 118.323 25.5568 119.893 26.4783C121.664 27.5173 123.185 28.9732 126.218 31.878L153.65 58.1422C156.686 61.0489 158.195 62.4982 159.281 64.1943C160.243 65.698 160.955 67.3376 161.385 69.0525C161.749 70.5014 161.842 72.0203 161.866 74.4194M109.214 24.0073V47.529C109.214 56.9415 109.214 61.6445 111.127 65.2396C112.81 68.4019 115.493 70.9781 118.796 72.5894C122.547 74.4194 127.46 74.4194 137.272 74.4194H161.866M161.866 74.4194H161.876" stroke="#099CEC" stroke-width="9.15285" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="49.2395" y="68.1162" width="107.516" height="102.941" fill="url(#pattern0_12315_218)"/></g>
<defs>
<pattern id="pattern0_12315_218" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_12315_218" transform="translate(0 -0.0166667) scale(0.00403646)"/>
</pattern>
<image id="image0_12315_218" width="256" height="256" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAACi1BMVEUAAAAInOwJm+wHnegPlvAIm+wJm+sJm+sGm+wA//8Aqv8MneYJnOsInOsNofEAn98Af/8InOsAqv8AkdoJm+wHmu8Jm+wPn+8Km+sKnOoInOsJnOwLnu4Im+wJnO0Im+0Inu0AlOkIme0InOsKnOoJnOwIm+sInOwJm+sKnekJnOwGmesInOwJnOsJm+wJm+wJnOwJm+sJnOsAquIIm+wJm+sJm+wIm+sInOsJm+wIm+sGnuoMmfILnO0Jm+sKmeoHnOwJnOsJnOwJnOwKm+oInOsLmu0Im+wInOsInOsIm+sJnOsJmusInOwInOoJm+wIm+wIm+wJnOsInOsJm+sJnOsJnOwHnOwJmuwJm+wHmusJm+sHmekJnOsIme4Im+wJnewIm+wIneoKmukHm+wJnOsJnOwHn+8InOsInOwJm+wIm+sIm+wJm+sInOwJnOsJmuwInOsJnOsIm+wJm+sLm+gLlucJoOwJnOsJnOsJm+sJnOwIm+wKmu0GmuoJm+sInewInOsJm+sLm+4Im+sJnOsJmesIm+sImuoIm+sKm+sJm+wHm+kIm+sJm+sInOwIm+sInOwJnOsInO4JnOsIne4Km+0Rme4Jm+wAnOsInOwHm+wJnOsIm+sKnuoInOwInO0GnewJm+sIm+wOm/AInOsJnOsInOwKne0JmuwJnOwJm+wIm+sIm+wHnOsAmf8Jm+wJnOwHnOwInO0JnOwJnOwInOsJm+sAmeUInOsHnewInOsHm+sIm+sJm+wJnewInOsKm+0Ao+wInOwAf/8JnOwAoucJnOsJnOwInOwJm+sJm+sGnOsJm+sInOwJm+wInOwJm+wJm+sInOwJm+wHnewJnOsJnOwJm+zwClkZAAAA2XRSTlMAejYiEedNgykBAxX6dxMIAvAGB6Qh9hAzS5H4LbFVVx0MOl0xidDogC/zKMz9wPKMqfsJtJxu5HTYriUULJ0ZYNbCo0qbK+qOW8hOa5Y+il95ULjGhKZiVL5CaSPEHpdwmj8wRWjcIMm1Uu97oX+GOOOezWcXFhsagfFvfkcmu17H3y5zNDWsPdNm3SRahc+2fLofxTxkD/cNmWGq7DKzOSrgzhLR4cpJHPW8duVDBaK/RnJtU63DCtRE60CS9FHuSA7pBL0LoNeU/vkn/ObamHGfsMFj3v+IzzwotQAABUNJREFUeNrt2OVzFEkcxvEHQkJ24w4kuLu7u567u7u7K+7uboHgBELcZS/kEnjy5xxXV9TdhZmwMrM73fP7vH2q33yrq7qqIYQQQgghhBBCCCGEEEIIIYQQQgghhBBCCCGEEEIIK3Q29hjcgsZaJIAEkAASQAJIAAkgASSABJAAEkACSAAJIAEkgASQABJASzHG+sh/uRBK2X7D2O8AUk22qwBgss2DYqJo7E8AnWisub1XQAJIAAkgASSABAhM4vkKdwd4lAP6uTlADMn41lTXBjgUzb8NG+rWAOn8x8gYdwboytt8TW4MkH+T/6r1uC/A1/yvGcluC9Aphf+T7nVZgEFs4x53BUgdyLYmuSrAYt5h+JtuCvAQ7/TWa+4JEDeGBuZ4XRNgFg3lInzSOhrLAOA12aYCgMk2FIEYTUNDZsIdPAU0NgPusIomhsfCFVbTzHVYKbGDsVIAWSZbVwBJJttFAGgy1gMALhif+xhtrKGZzGmwUKxZZrtegT9oqBFt/EZTz7siwDCaanFFgIk05YtzQ4B6mlvsggAVbMciFwSoYjtGAEDvXjldbmmY5tEyQG+2Y+So5pp63lZXvuSdt7tmaRbAywANH9B/sE4BkMnAXdnp1SfATQbjylVtAlxmUOp2bNckQDWD9ONpPQI0MVh1hVoEuMagVWsRICmTQfJ9q0UAnAz+AugR4AKD42vQJEBcPINyBpoEwKUgL4A2AToyGLUIzNM3jXUG0Mtk6w+gzGS7DgDRxqYAwA/G5ypxp7MMXMEh6KOfjwHbAo2kzmGgunugja25pxioDxKhiay+zdsYsBPvQwfHj31xsp5BiD+Gu4prdbTr1evWfskg1ffE3b1MbQ0phR9eoa4mD4Y/RlFT86vgl9HUUs05+KmSGpo9ywt/HaZu4ptjkuG/U9TK7l074xCQEurBF/3w3EGrGzwIVBEVV9IQFRWVmIRg+ai4VoSmjmob8yxCk021jUaIUqi2fQjRQirtLEL1KpV2DqF6nSo76EGo3qDKihGyM1TYMA9CtoYKK0XotlBdl2GB01TXZlggg8qaAiskD6Gi6qfCEkuoqFZY4wWqqbwM1lhGNfWFRfpRSSM8sIg3hQrKHgrLbKCC1sM666mej/JgnV+pnOwcWCi5nKpJgKXSqZhNXliqJ9VSshHWyi+iUophtV1USS0sd5EKOZoEy6UtpDLKj8MGi6iKMddgh1iqYgLs0UI1FMImD1IJ47ywSXINFXA0H7a5Qedr/AT2STtCpztwHICLr0B5A2xVMZmONnA6bPY4nSw6FrZ7gs4VvR/2WxBPp1q5CuFwHx1q+WcIi94r6UhntyJMNvvoQOlpCJv+dJy6QR6ET9paOkxRMcJqYwEdpfEnhNlVHx2kexbCrgMdo77JgwiopUPU7EFEeC/REc5UIULyLzPyjpQicqoqGWF1O8oAFxeYfS8ibGwzI2fkpDxEnLcbI6Ru7kw4wlc+RkLlL3CKa9EMu8PH4CBx3zG8ln/vhaNUPJDN8DlY7IHj7D/KMHkmxgMnqjifQvtlzl8Ax0rs5qO9ahIS4Wh79tI+KeP7wPk6rqMt6rv3SIIaGrqdoMW27V3xKRQS16GR1jn14cUqKKfPcyW0wImTuYOhqLzThQcYit17c/flQW05Ey4VMAglI0b1jIIePNPvL9xURH9NbBl//tzn0E1yxjcXascNGEgT8dFXNlQ/smxpzljoLbXXSy8u7bvi54SEd99LuOWpGz3mPdklowxCCCGEEEIIIYQQQgghhBBCCCGEEEIIIYQQQgghhAifvwDAua/EpaY9sQAAAABJRU5ErkJggg=="/>
</defs>
<g transform="translate(49.24,65.83) scale(4.4798)"><path fill="#099CEC" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></g></g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

+470 -1
View File
@@ -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 <html> 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 <html> 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; }
}
+43
View File
@@ -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) {
+246
View File
@@ -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);
}
@@ -0,0 +1,165 @@
<script>
import { onMount } from 'svelte';
import { ICONS, load, nodeById, PALETTE, save, STORE, withoutSecrets } from '../../deploy-canvas.js';
import Dropdown from './Dropdown.svelte';
import Edges from './Edges.svelte';
import Icon from './Icon.svelte';
import Node from './Node.svelte';
import PaletteItem from './PaletteItem.svelte';
import RunPanel from './RunPanel.svelte';
import { closeMenu, cv, dock, els, fit, hideResult, leave, pan, pickNext, PORT_Y, resetCanvas } from './state.svelte.js';
// The deploy canvas: header, step palette, and the canvas with the step
// cards. The state lives in state.svelte.js (init() runs before the mount).
let zoomLabel = $state();
// Theme: same as Coolify (html.dark).
let dark = $state(document.documentElement.classList.contains('dark'));
// Palette: full or icons only ('coolify-canvas:collapsed').
let folded = $state(load('collapsed', false));
// Small screens: a notice that the canvas is not optimized for mobile. CSS
// shows it below 768px; closed once, it stays closed.
let noticeOpen = $state(!load('mobile-notice-dismissed', false));
const stop = e => e.stopPropagation();
// A pill joined to the Deploy card: each address opens in a new tab.
const resultNode = $derived(cv.result && nodeById(cv.result.after, cv.flow));
const foldTitle = $derived(folded ? 'Show the step names' : 'Collapse the steps');
function toggleFolded() {
save('collapsed', !load('collapsed', false));
folded = load('collapsed', false);
}
function closeNotice() {
save('mobile-notice-dismissed', true);
noticeOpen = false;
}
// Opens the app's confirmation modal (deploy-canvas.blade.php), which
// dispatches deploy-canvas-reset on confirm.
function askReset() {
if (!cv.running) document.getElementById('deploy-canvas-reset-trigger')?.click();
}
// Save the flow (without secrets) and the view, 200 ms after a change.
let saveTimer = 0;
$effect(() => {
const flow = JSON.stringify(withoutSecrets(cv.flow));
const view = JSON.stringify(cv.view);
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
localStorage.setItem(`${STORE}:flow`, flow);
localStorage.setItem(`${STORE}:view`, view);
}, 200);
});
onMount(() => {
els.pz.apply();
if (els.fitOnOpen) requestAnimationFrame(fit);
const theme = new MutationObserver(() => { dark = document.documentElement.classList.contains('dark'); });
theme.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
const escape = e => { if (els.root?.isConnected && e.key === 'Escape') closeMenu(); };
const reset = () => { if (els.root?.isConnected) resetCanvas(); };
addEventListener('keydown', escape);
addEventListener('deploy-canvas-reset', reset);
return () => {
theme.disconnect();
removeEventListener('keydown', escape);
removeEventListener('deploy-canvas-reset', reset);
leave();
};
});
</script>
<div class="cv-root" class:cv-dark={dark} class:cv-folded={folded}>
<!-- Header: placed by dock (app top bar, floating panel, or a box above the canvas). -->
<div use:dock>
<div class="mr-auto flex min-w-0 items-center gap-2">
{#if cv.target}
<!-- Opened from an environment's New resource page: back to that environment. -->
<a class="cv-back button px-2!" href="/project/{cv.target.project}/environment/{cv.target.environment}" title="Back to the environment" aria-label="Back to the environment"><Icon name="back" cls="size-4" /></a>
{/if}
<b class="shrink-0 text-[14px] font-semibold text-black dark:text-fg">Deploy canvas</b>
<span class="shrink-0 rounded-full border border-coollabs/30 bg-coollabs/10 px-2 py-0.5 text-xs font-medium text-coollabs dark:border-warning/30 dark:bg-warning/15 dark:text-warning">Beta</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<!-- Zoom controls, as on the environment canvas header. -->
<div class="flex items-center">
<button type="button" class="button px-2! rounded-r-none!" title="Zoom out" aria-label="Zoom out" onclick={() => els.pz.zoomTo(cv.view.z / 1.2)}><Icon name={ICONS.minus} cls="size-4" /></button>
<!-- Its text is the zoom level, written by panZoom. -->
<button bind:this={zoomLabel} type="button" class="button rounded-none! border-x-0! w-14 tabular-nums" title="Reset zoom to 100%" onclick={() => els.pz.zoomTo(1)}>100%</button>
<button type="button" class="button px-2! rounded-l-none!" title="Zoom in" aria-label="Zoom in" onclick={() => els.pz.zoomTo(cv.view.z * 1.2)}><Icon name={ICONS.plus} cls="size-4" /></button>
</div>
<button type="button" class="button px-2!" title="Fit to screen" aria-label="Fit to screen" onclick={fit}><Icon name={ICONS.fit} cls="size-4" />Fit</button>
<button type="button" class="button" title="Start again with an empty canvas" onclick={askReset}>Reset</button>
</div>
</div>
{#if noticeOpen}
<div class="cv-island cv-notice" role="note">
<Icon name={ICONS.info} />
<p>The canvas is not optimized for mobile yet. It works best on a larger screen.{#if cv.target}{' On a phone, use the '}<a href="/project/{cv.target.project}/environment/{cv.target.environment}/new">New resource page</a>.{/if}</p>
<button type="button" class="cv-x" title="Close" aria-label="Close this notice" onclick={closeNotice}><Icon name={ICONS.x} /></button>
</div>
{/if}
<aside class="cv-island cv-palette">
<div class="cv-palette-head">
<div><b>Steps</b><span>Drag one onto the canvas</span></div>
<button type="button" class="cv-x cv-fold" title={foldTitle} aria-label={foldTitle} aria-expanded={!folded} onclick={toggleFolded}><Icon name={ICONS.fold} /></button>
</div>
{#each PALETTE as [group, types]}
<h4>{group}</h4>
{#each types as type}
<PaletteItem {type} />
{/each}
{/each}
</aside>
<section class="cv-island cv-stage">
<div class="cv-viewport" use:pan={() => zoomLabel}>
<div class="cv-world">
<Edges />
{#each cv.flow.nodes as n (n.id)}
<Node {n} />
{/each}
{#if resultNode}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="cv-result"
style:left="{resultNode.x + (cv.sizes[resultNode.id]?.w || 296) + 72}px"
style:top="{resultNode.y + PORT_Y - 19}px"
onpointerdown={stop}
>
<span class="cv-port in"></span>
<i></i>
{#if cv.result.urls.length}
{#each cv.result.urls.slice(0, 3) as url}
<a href={url} target="_blank" rel="noopener noreferrer" title="Open {url}">{url.replace(/^https?:\/\//, '')}<Icon name={ICONS.out} /></a>
{/each}
{:else}
<a href={cv.result.href} target="_blank">Open in Coolify</a>
{/if}
<button class="cv-x" title="Hide" onclick={hideResult}><Icon name={ICONS.x} /></button>
</div>
{/if}
</div>
</div>
<RunPanel />
{#if cv.menu}
<div class="cv-menu" style:left="{cv.menu.left}px" style:top="{cv.menu.top}px">
<h5>Add the next step</h5>
{#each cv.menu.valid as type}
<PaletteItem {type} onpick={() => pickNext(type)} />
{/each}
</div>
{/if}
{#if cv.toast}
<div class="cv-toast" class:bad={cv.toast.bad}>{cv.toast.text}</div>
{/if}
</section>
{#if cv.dropdown}
{#key cv.dropdown?.trigger}
<Dropdown />
{/key}
{/if}
</div>
+137
View File
@@ -0,0 +1,137 @@
<script>
import { onMount } from 'svelte';
import { ICONS } from '../../deploy-canvas.js';
import Icon from './Icon.svelte';
import { closeDropdown, cv, toast } from './state.svelte.js';
// Our dropdown panel: a searchable list under its trigger, and an optional
// "New …" row. Mounted again for each trigger (see DeployCanvas.svelte).
const { trigger, items, current, onPick, add } = cv.dropdown;
let panel;
let search = $state();
let listBox;
let addInput = $state();
let query = $state('');
let active = $state(Math.max(0, items.findIndex(([v]) => v === current)));
// The "New …" row: a click shows a name field; Enter creates it.
let adding = $state(false);
let name = $state('');
let busy = $state(false);
let problem = $state('');
const q = $derived(query.trim().toLowerCase());
const shown = $derived(items.filter(([, text]) => !q || String(text).toLowerCase().includes(q)));
const activeIndex = $derived(Math.min(active, Math.max(0, shown.length - 1)));
function pick(value) {
closeDropdown();
onPick(value);
}
function keys(e) {
if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
trigger.focus();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
active = (activeIndex + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % Math.max(1, shown.length);
} else if (e.key === 'Enter' && shown[activeIndex]) {
e.preventDefault();
pick(shown[activeIndex][0]);
}
}
function startAdding() {
name = query.trim();
adding = true;
}
async function submit(e) {
e.preventDefault();
const typed = name.trim();
if (!typed || busy) return;
busy = true;
try {
const created = await add.create(typed);
closeDropdown();
add.done(created);
toast(`"${typed}" added.`);
} catch (error) {
problem = error.message;
busy = false;
addInput?.focus();
}
}
// Keep the active option in view.
$effect(() => {
shown;
listBox.children[activeIndex]?.scrollIntoView({ block: 'nearest' });
});
$effect(() => {
if (adding) addInput?.focus();
});
onMount(() => {
// Under the trigger (or above it when there is no room), as wide as it.
const r = trigger.getBoundingClientRect();
const width = Math.max(r.width, 200);
const below = innerHeight - r.bottom - 12;
panel.style.width = `${width}px`;
panel.style.left = `${Math.min(r.left, innerWidth - width - 12)}px`;
if (below < Math.min(panel.offsetHeight, 260) && r.top > below) panel.style.bottom = `${innerHeight - r.top + 6}px`;
else panel.style.top = `${r.bottom + 6}px`;
(search || listBox.children[activeIndex] || panel).focus?.();
const outside = e => {
if (!panel.contains(e.target) && e.target !== trigger && !trigger.contains(e.target)) closeDropdown();
};
addEventListener('pointerdown', outside, true);
return () => removeEventListener('pointerdown', outside, true);
});
</script>
<div class="cv-dd" role="listbox" tabindex="-1" bind:this={panel} onkeydown={keys}>
{#if items.length > 8}
<input class="cv-dd-search" placeholder="Search" spellcheck="false" bind:this={search} bind:value={query} oninput={() => (active = 0)} />
{/if}
<div class="cv-dd-list" bind:this={listBox}>
{#each shown as [value, text, badge], i}
<button
type="button"
role="option"
aria-selected={value === current}
class="cv-dd-opt"
class:on={value === current}
class:active={i === activeIndex}
onpointerenter={() => (active = i)}
onclick={() => pick(value)}
>
<span>{text}</span>
{#if badge}<em class="cv-badge">{badge}</em>{/if}
<Icon name={ICONS.check} />
</button>
{:else}
<p class="cv-dd-none">{q ? 'No match' : 'None yet'}</p>
{/each}
</div>
{#if add}
<div class="cv-dd-add">
{#if adding}
<form class="cv-dd-form" onsubmit={submit}>
<!-- Enter submits the form, not the list's "pick the active option". -->
<input
class="cv-dd-search"
placeholder={add.placeholder}
spellcheck="false"
bind:this={addInput}
bind:value={name}
onkeydown={e => { if (e.key !== 'Escape') e.stopPropagation(); }}
/>
<button type="submit" class="button button-highlighted" disabled={busy}>Add</button>
</form>
<p class="cv-note bad">{problem}</p>
{:else}
<button type="button" class="cv-dd-opt cv-dd-new" onclick={startAdding}><Icon name={ICONS.plus} /><span>{add.label}</span></button>
{/if}
</div>
{/if}
</div>
+48
View File
@@ -0,0 +1,48 @@
<script>
import { nodeById, planOf } from '../../deploy-canvas.js';
import { curve, cv, inPoint, outPoint, removeEdge } from './state.svelte.js';
// The lines: connections (click one to remove it), every resource line into
// Deploy, the result pill's line, and the line being dragged.
const lines = $derived.by(() => {
const flow = cv.flow;
const runState = cv.runState;
const out = [];
for (const edge of flow.edges) {
const a = nodeById(edge.from, flow);
const b = nodeById(edge.to, flow);
if (!a || !b) continue;
const st = runState[edge.to]?.state;
const cls = st === 'running' ? 'flow' : runState[edge.from]?.state === 'done' && st ? 'live' : null;
// Start's connection to the first Server cannot be removed.
const removable = !(edge.from === 'start' && edge.to === 'server');
out.push({ d: curve(outPoint(a), inPoint(b)), cls, edge: removable ? edge : null });
}
const plan = planOf(flow);
const st = plan.deploy && runState[plan.deploy.id]?.state;
for (const { end } of plan.lines) {
out.push({ d: curve(outPoint(end), inPoint(plan.deploy)), cls: st === 'running' ? 'flow' : st === 'done' ? 'live' : null, edge: null });
}
const after = cv.result && nodeById(cv.result.after, flow);
if (after) {
const a = outPoint(after);
out.push({ d: curve(a, { x: a.x + 72, y: a.y }), cls: 'live', edge: null });
}
return out;
});
const dragged = $derived(cv.temp ?? cv.pendingLine);
</script>
<svg class="cv-edges">
{#each lines as line}
{#if line.edge}
<!-- Pointer only, like the rest of the canvas. -->
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<path class="hit" d={line.d} onclick={() => removeEdge(line.edge)}><title>Click to remove this connection</title></path>
{/if}
<path class={line.cls} d={line.d} />
{/each}
{#if dragged}
<path class="temp" d={curve(dragged.a, dragged.b)} />
{/if}
</svg>
+105
View File
@@ -0,0 +1,105 @@
<script>
import { ICONS, lists, loaderKey } from '../../deploy-canvas.js';
import Icon from './Icon.svelte';
import { cv, openDropdown, setField } from './state.svelte.js';
// One field of a card: a note, segmented buttons, our dropdown, or a text input.
let { n, f } = $props();
let trigger = $state();
const stop = e => e.stopPropagation();
const set = value => setField(n, f, value);
// Select options: null while loading; undefined / '' when the field above is empty.
const list = $derived.by(() => {
cv.listsVersion;
return f.kind === 'select' ? (f.load ? f.load(n.data) : f.options) : null;
});
const error = $derived.by(() => {
cv.listsVersion;
return f.load ? lists.errors[loaderKey(f, n.data)] : null;
});
const value = $derived(n.data[f.key] ?? '');
// An optional field's default ("Default") is a real choice, not a placeholder.
const chosen = $derived((list || []).find(([v]) => v === value) || (list && f.optional && !value ? ['', f.optional] : null));
const placeholder = $derived(list ? (f.optional || `Choose ${f.label.toLowerCase()}`) : list === null ? 'Loading…' : 'Pick the field above first');
// `add`: a "New …" row at the end of the list creates one and selects it.
const add = $derived(f.add && cv.canAdd ? {
...f.add,
create: name => f.add.create(name, n.data),
// Load the list again (it has the new one), then select it.
done: picked => {
delete lists.cache[f.add.list(n.data)];
cv.listsVersion++;
set(picked);
},
} : null);
function open() {
openDropdown(trigger, [...(f.optional ? [['', f.optional]] : []), ...(list || [])], value, set, add);
}
function keys(e) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
trigger.click();
}
}
</script>
{#if f.kind === 'note'}
<p class="cv-note">{f.text}</p>
{:else}
<div class="cv-field">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>{f.label}{#if f.required}<em>{' *'}</em>{/if}</label>
{#if f.kind === 'segmented'}
<div class="cv-seg">
{#each f.options as [option, text]}
<button type="button" class={(n.data[f.key] ?? f.value) === option ? 'on' : ''} onclick={() => set(option)} onpointerdown={stop}>{text}</button>
{/each}
</div>
{:else if f.kind === 'select'}
<button
bind:this={trigger}
type="button"
class="cv-input cv-select"
class:empty={!chosen}
class:open={!!trigger && cv.dropdown?.trigger === trigger}
disabled={!list}
aria-haspopup="listbox"
onpointerdown={stop}
onclick={open}
onkeydown={keys}
>
<span>{chosen ? chosen[1] : placeholder}</span>
{#if chosen?.[2]}<em class="cv-badge">{chosen[2]}</em>{/if}
<Icon name={ICONS.chevron} />
</button>
{#if error}<span class="cv-note bad">{error}</span>{/if}
{:else if f.kind === 'textarea'}
<textarea
class="cv-input"
class:mono={f.mono}
rows={f.rows || 3}
placeholder={f.placeholder || ''}
spellcheck="false"
value={n.data[f.key] ?? ''}
oninput={e => set(e.currentTarget.value)}
onpointerdown={stop}
onkeydown={stop}
></textarea>
{:else}
<input
type="text"
class="cv-input"
class:mono={f.mono}
placeholder={f.placeholder || ''}
spellcheck="false"
value={n.data[f.key] ?? ''}
oninput={e => set(e.currentTarget.value)}
onpointerdown={stop}
onkeydown={stop}
/>
{/if}
</div>
{/if}
+25
View File
@@ -0,0 +1,25 @@
<script>
import { LOCAL_ICONS } from '../../deploy-canvas.js';
// The app's reicon glyphs (rendered by the page into [data-deploy-canvas-icons]),
// or a few local paths in the same style. The svg is the direct child that the
// canvas CSS expects.
let { name, cls = '' } = $props();
const glyph = $derived.by(() => {
const node = document.querySelector('[data-deploy-canvas-icons]')?.content?.querySelector(`[data-icon="${name}"]`)?.cloneNode(true);
if (!node) return null;
if (cls) node.setAttribute('class', cls);
else node.removeAttribute('class');
node.setAttribute('aria-hidden', 'true');
return node.outerHTML;
});
</script>
{#if glyph}
{@html glyph}
{:else}
<svg class={cls || undefined} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
{#each LOCAL_ICONS[name] ?? [] as d}<path {d} />{/each}
</svg>
{/if}
+104
View File
@@ -0,0 +1,104 @@
<script>
import { ICONS, logoOf, nextOf, onlyOption, planOf, prevOf, resourceLabel, sourceAbove, TYPES } from '../../deploy-canvas.js';
import Field from './Field.svelte';
import Icon from './Icon.svelte';
import { cv, dragNode, removeNode, runFlow, startConnect, track } from './state.svelte.js';
// A step card: header (drag handle), fields, ports, and its run status.
let { n } = $props();
const stop = e => e.stopPropagation();
const t = $derived(TYPES[n.type]);
const st = $derived(cv.runState[n.id]);
const plan = $derived(planOf(cv.flow));
// The logo of what the step creates (engine, template, build); a logo that fails to load
// shows its fallback, then the step's glyph.
const logo = $derived(logoOf(n));
let broken = $state({});
const logoSrc = $derived(logo && (broken[logo.src] ? logo.fallback : logo.src));
const fields = $derived(t.fields.filter(f => !f.show || f.show(n.data)));
// Start, Deploy and the first Server stay.
const removable = $derived(n.type !== 'start' && n.type !== 'deploy' && n.id !== 'server');
const hasOut = $derived(!!nextOf(n.id, cv.flow) || plan.lines.some(l => l.end.id === n.id));
const hasIn = $derived(!!prevOf(n.id, cv.flow) || (n.type === 'deploy' && plan.lines.length > 0));
// Blank canvas: the Server ring pulses and a dashed trail shows where to drag it.
const blank = $derived(n.id === 'server' && cv.flow.nodes.every(x => ['start', 'server', 'deploy'].includes(x.type)));
// A select with `only: true` takes its option when it has exactly one.
$effect(() => {
cv.listsVersion;
for (const f of fields) {
if (f.kind !== 'select' || !f.only) continue;
const only = onlyOption(f.load ? f.load(n.data) : f.options, n.data[f.key]);
if (only && only !== n.data[f.key]) n.data[f.key] = only;
}
});
</script>
<div
class="cv-node"
class:start={n.type === 'start'}
class:running={st?.state === 'running'}
class:done={st?.state === 'done'}
class:failed={st?.state === 'failed'}
class:invalid={cv.invalid[n.id]}
class:drop-ok={cv.dropTarget === n.id}
class:has-out={hasOut}
class:has-in={hasIn}
class:blank
data-id={n.id}
style:left="{n.x}px"
style:top="{n.y}px"
use:track={n.id}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="cv-node-head" onpointerdown={e => dragNode(n, e)}>
<i class:cv-logo={logoSrc && !broken[logoSrc]} class:padded={logo?.padded}>
{#if logoSrc && !broken[logoSrc]}
<img class:cv-light={logo.dark} alt="" src={logoSrc} onerror={() => (broken[logoSrc] = true)} />
{#if logo.dark}<img class="cv-dark-only" alt="" src={logo.dark} />{/if}
{:else}
<Icon name={t.icon} />
{/if}
</i>
<div><b>{t.title}</b><small>{t.sub}</small></div>
{#if removable}
<button class="cv-x" title="Remove step" onpointerdown={stop} onclick={() => removeNode(n.id)}><Icon name={ICONS.x} /></button>
{/if}
</div>
<div class="cv-node-body">
{#each fields as f (f)}
<Field {n} {f} />
{/each}
{#if n.type === 'deploy'}
{#if !plan.lines.length}
<p class="cv-note">Every resource you connect to a Server shows here.</p>
{/if}
{#each plan.lines as { source } (source.id)}
<!-- svelte-ignore a11y_label_has_associated_control -->
<div class="cv-field"><label>{resourceLabel(source)}</label></div>
{/each}
<!-- Save only creates the resources in Coolify; Deploy also starts them. -->
<div class="cv-actions">
<button type="button" class="button cv-deploy-now" disabled={!!cv.running} title="Create the resources in Coolify without starting them" onpointerdown={stop} onclick={() => runFlow('save')}>
<span>{cv.running === 'save' ? 'Working…' : 'Save only'}</span>
</button>
<button type="button" class="button button-highlighted cv-deploy-now" disabled={!!cv.running} title="Create the resources and deploy them" onpointerdown={stop} onclick={() => runFlow('deploy')}>
<Icon name={ICONS.deploy} /><span>{cv.running === 'deploy' ? 'Working…' : 'Deploy'}</span>
</button>
</div>
{/if}
{#if t.stage === 'config' && !sourceAbove(n.id, cv.flow)}
<p class="cv-note">Connect it after a source step.</p>
{/if}
</div>
{#if n.type !== 'start'}<span class="cv-port in"></span>{/if}
{#if st?.message}<div class="cv-status {st.state}">{st.message}</div>{/if}
{#if n.type !== 'deploy'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="cv-port out" class:armed={cv.armed === n.id} title="Drag to connect the next step" onpointerdown={e => startConnect(n, e)}></span>
{/if}
{#if blank}
<div class="cv-hint"><span>Drag and drop to add a step</span></div>
{/if}
</div>
@@ -0,0 +1,21 @@
<script>
import { TYPES } from '../../deploy-canvas.js';
import Icon from './Icon.svelte';
import { dragFromPalette } from './state.svelte.js';
// A step in the palette (drag it, or click to add it to the chain), or in
// the "Add the next step" menu (onpick).
let { type, onpick = null } = $props();
const t = $derived(TYPES[type]);
</script>
<button
type="button"
class="cv-item"
title="{t.title} — {t.sub}"
onclick={onpick}
onpointerdown={onpick ? null : e => dragFromPalette(type, e)}
>
<i><Icon name={t.icon} /></i>
<span><b>{t.title}</b><small>{t.sub}</small></span>
</button>
@@ -0,0 +1,48 @@
<script>
import { ICONS } from '../../deploy-canvas.js';
import Icon from './Icon.svelte';
import { cv, showPane } from './state.svelte.js';
// Run panel: an island with the steps as status lights, then the logs
// ("Steps", and a tab per deployed resource).
const r = $derived(cv.run);
// "Docker image · nginx #2" → "nginx #2"; the full name is the tooltip.
const short = label => (label.includes(' · ') ? label.slice(label.indexOf(' · ') + 3) : label);
</script>
<aside class="cv-run" hidden={!r.open}>
<header>
<div><b>{r.title}</b><span>{r.sub}</span></div>
<button class="cv-x" title="Close" onclick={() => { if (!cv.running) r.open = false; }}><Icon name={ICONS.x} /></button>
</header>
<div class="cv-steps">
{#each r.steps as step (step.id)}
<div class="cv-step {step.state}" data-id={step.id}>
<i></i>
<div><span>{step.label}</span><small>{step.message}</small></div>
</div>
{/each}
</div>
<div class="cv-seg cv-tabs" role="tablist" hidden={r.panes.length < 2}>
{#each r.panes as pane (pane.key)}
<button
type="button"
role="tab"
class:on={r.active === pane.key}
aria-selected={r.active === pane.key}
title="{pane.label}{pane.status ? `: ${pane.status}` : ''}"
onclick={() => showPane(pane.key)}
>
<i class={pane.state}></i><span>{short(pane.label)}</span>
</button>
{/each}
</div>
<div class="cv-logs-wrap">
{#each r.panes as pane (pane.key)}
<pre class="cv-logs" data-key={pane.key} hidden={r.active !== pane.key}>{#each pane.lines as line}<span class={line.cls}>{`${line.text}\n`}</span>{/each}</pre>
{/each}
</div>
<footer>
<a class="button" target="_blank" href={r.openHref || undefined} hidden={!r.showOpen}>Open</a>
</footer>
</aside>
+18
View File
@@ -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) };
});
}
+661
View File
@@ -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);
}
}
+319
View File
@@ -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
// <style> (outside the CSS build, which would rewrite light-dark()).
const CSS = `
.cv-root {
/* The look of the environment canvas (environment-canvas.js): the app's
own color tokens (app.css), so custom color schemes apply too. */
--cv-island: light-dark(#ffffff, var(--color-panel));
--cv-backdrop: light-dark(#fafafa, var(--color-app));
--cv-card: light-dark(#ffffff, var(--color-raised));
--cv-popover: light-dark(#ffffff, var(--color-panel));
--cv-text: light-dark(#171717, var(--color-fg));
--cv-subtle: light-dark(#737373, var(--color-fg-faint));
--cv-placeholder: light-dark(#a3a3a3, var(--color-fg-faint));
/* The app's card border (app.css), as on the environment canvas cards. */
--cv-line: var(--coollabs-hairline, light-dark(#e5e5e5, rgb(255 255 255 / 0.08)));
--cv-input-bg: light-dark(#fafafa, rgb(255 255 255 / 0.04));
--cv-input-line: light-dark(#e5e5e5, rgb(255 255 255 / 0.08));
--cv-brand: light-dark(var(--color-coollabs), var(--color-warning));
--cv-brand-text: var(--cv-brand);
position: absolute; inset: 0; z-index: 10;
display: grid; grid-template-columns: 15.5rem minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
gap: 8px; padding: 8px;
background: var(--cv-backdrop); color: var(--cv-text);
color-scheme: light; font: 13px/1.4 var(--font-sans, ui-sans-serif, system-ui, sans-serif);
-webkit-font-smoothing: antialiased;
}
.cv-root.cv-dark { color-scheme: dark; }
.cv-root *, .cv-root *::before, .cv-root *::after { box-sizing: border-box; }
/* Rounded boxes on the backdrop: the top bar and the step list. */
.cv-island { border: 1px solid var(--cv-line); border-radius: 12px; background: var(--cv-island); }
.cv-root svg { width: 1rem; height: 1rem; flex: none; }
.cv-root button:not(.button) { font: inherit; }
/* Header island on small screens; on desktop the header docks into the app top bar. */
.cv-top { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; padding: 0.625rem 0.75rem; }
.cv-root.cv-docked { grid-template-rows: minmax(0, 1fr); }
.cv-btn {
display: inline-flex; align-items: center; gap: 0.4375rem; height: 2rem; padding: 0 0.75rem;
border: 1px solid var(--cv-line); border-radius: 8px; background: var(--cv-input-bg);
color: var(--cv-text); font-weight: 500; font-size: 12.5px; cursor: pointer; text-decoration: none; white-space: nowrap;
transition: border-color 150ms ease, background-color 150ms ease;
}
.cv-btn:hover { border-color: light-dark(rgb(0 0 0 / 0.16), rgb(255 255 255 / 0.16)); }
.cv-btn svg { width: 0.875rem; height: 0.875rem; }
.cv-btn.icon { width: 2rem; padding: 0; justify-content: center; }
.cv-btn.ghost { border-color: transparent; background: transparent; }
.cv-btn.ghost:hover { background: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.05)); }
/* Small-screen notice: only below 768px, until it is closed. */
.cv-notice { display: none; }
@media (max-width: 767px) {
.cv-root:has(> .cv-notice) { grid-template-rows: auto auto minmax(0, 1fr); }
.cv-notice { grid-column: 1 / -1; display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.625rem 0.5rem 0.625rem 0.75rem; background: #dc2626; color: #fff; font-size: 12.5px; line-height: 1.125rem; }
.cv-notice > svg { margin-top: 0.0625rem; }
.cv-notice p { flex: 1; min-width: 0; margin: 0; }
.cv-notice a { color: inherit; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; }
.cv-notice .cv-x { opacity: 1; margin-top: -0.1875rem; color: inherit; }
.cv-notice .cv-x:hover { background: rgb(255 255 255 / 0.18); color: inherit; }
}
/* Palette island */
/* Hugs its steps instead of filling the row; scrolls when the screen is short. */
.cv-palette { display: flex; flex-direction: column; align-self: start; max-height: 100%; overflow-y: auto; padding: 1rem 0.625rem 0.75rem; }
.cv-palette-head { padding: 0 0.5rem 0.75rem; margin: 0 -0.625rem 0.5rem; padding-inline: 1.125rem; border-bottom: 1px solid var(--cv-line); }
.cv-palette-head b { display: block; font-size: 14px; font-weight: 600; }
.cv-palette-head span { color: var(--cv-subtle); font-size: 12px; }
.cv-palette h4 { margin: 0.75rem 0.5rem 0.25rem; color: var(--cv-subtle); font-size: 11px; font-weight: 500; }
.cv-item {
display: flex; align-items: center; gap: 0.625rem; width: 100%; min-height: 2.5rem; padding: 0.25rem 0.5rem 0.25rem 0.375rem;
border: 0; border-radius: 9px; background: none; color: var(--cv-text); text-align: left;
cursor: grab; user-select: none; touch-action: none;
}
/* Icon tiles: the neutral 32px tile of Coolify's collection cards. */
.cv-item i, .cv-node-head i { display: grid; flex: none; place-items: center; width: 2rem; height: 2rem; border: 1px solid var(--cv-line); border-radius: 8px; background: light-dark(#fafafa, rgb(255 255 255 / 0.04)); color: light-dark(#737373, var(--color-fg-dim, #c4c4c4)); }
.cv-item i svg { width: 1rem; height: 1rem; }
/* A resource logo fills the tile, like the environment canvas cards. */
.cv-node-head i.cv-logo { overflow: hidden; }
.cv-node-head i.cv-logo img { width: 100%; height: 100%; object-fit: contain; }
.cv-node-head i.cv-logo.padded img { padding: 0.25rem; }
.cv-node-head .cv-dark-only, .cv-root.cv-dark .cv-node-head .cv-light { display: none; }
.cv-root.cv-dark .cv-node-head .cv-dark-only { display: block; }
/* Palette tiles use the app's 3D button depth (app.css .button): a 2px ledge,
a lift on hover, and a press on click. */
/* Only the tile reacts; the row itself has no hover background. */
.cv-item i { --cv-depth: light-dark(rgb(0 0 0 / 0.22), rgb(255 255 255 / 0.08)); box-shadow: 0 2px 0 var(--cv-depth); transition: border-color 120ms, background-color 120ms, box-shadow 80ms, transform 80ms; }
.cv-item:hover i { transform: translateY(-1px); box-shadow: 0 3px 0 var(--cv-depth); border-color: light-dark(rgb(0 0 0 / 0.18), rgb(255 255 255 / 0.18)); background: light-dark(#fff, rgb(255 255 255 / 0.08)); }
.cv-item:focus-visible { outline: none; }
.cv-item:focus-visible i { outline: 1px solid var(--color-accent, currentColor); outline-offset: 2px; }
.cv-item:active i { transform: translateY(2px); box-shadow: none; }
.cv-item b { display: block; font-size: 13px; font-weight: 500; }
.cv-item small { display: block; color: var(--cv-subtle); font-size: 11.5px; }
/* Canvas island */
.cv-stage { position: relative; overflow: hidden; border: 0; background: transparent; }
.cv-viewport {
position: absolute; inset: 0; cursor: grab; touch-action: none;
background-image: radial-gradient(circle, light-dark(rgb(0 0 0 / 0.13), rgb(255 255 255 / 0.09)) 1px, transparent 1.3px);
}
.cv-viewport.panning { cursor: grabbing; }
.cv-world { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
.cv-edges { position: absolute; top: 0; left: 0; width: 1px; height: 1px; overflow: visible; pointer-events: none; }
.cv-edges path { fill: none; stroke: light-dark(#a3a3a3, rgb(255 255 255 / 0.35)); stroke-width: 1.75; }
.cv-edges path.hit { stroke: transparent; stroke-width: 14; pointer-events: stroke; cursor: pointer; }
.cv-edges path.hit:hover + path, .cv-edges path.live { stroke: color-mix(in srgb, var(--cv-brand) 70%, transparent); }
/* The line being dragged: moving dashes, like the environment canvas (.ec-temp). */
.cv-edges path.temp { stroke: var(--cv-brand); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 6 6; animation: cv-flow 0.6s linear infinite; }
.cv-edges path.flow { stroke: var(--cv-brand); stroke-dasharray: 6 6; animation: cv-flow 0.8s linear infinite; }
@keyframes cv-flow { to { stroke-dashoffset: -12; } }
/* Nodes: a card like ux-polish's flow boxes — badge + title + subtitle, then fields. */
.cv-node {
position: absolute; width: 18.5rem;
border: 1px solid var(--cv-line); border-radius: 12px;
background: var(--cv-card);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
transition: box-shadow 150ms ease, border-color 150ms ease;
}
.cv-node:hover { border-color: light-dark(#d4d4d4, rgb(255 255 255 / 0.16)); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); }
.cv-node.start { width: 16.5rem; }
.cv-node.running { border-color: var(--cv-brand); box-shadow: 0 0 0 4px color-mix(in srgb, var(--cv-brand) 16%, transparent); }
.cv-node.done { border-color: color-mix(in srgb, #10b981 70%, transparent); }
.cv-node.failed, .cv-node.invalid { border-color: color-mix(in srgb, #ef4444 75%, transparent); box-shadow: 0 0 0 4px color-mix(in srgb, #ef4444 12%, transparent); }
.cv-node.drop-ok { border-color: var(--cv-brand); box-shadow: 0 0 0 4px color-mix(in srgb, var(--cv-brand) 16%, transparent); }
.cv-root { user-select: none; }
.cv-root :is(input, textarea, .cv-logs) { user-select: text; }
.cv-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.cv-deploy-now { width: 100%; }
.cv-node-head { display: flex; align-items: center; gap: 0.625rem; height: 3.25rem; padding: 0 0.5rem 0 0.75rem; cursor: grab; user-select: none; }
.cv-node-head i svg { width: 1rem; height: 1rem; }
.cv-node-head div { display: grid; min-width: 0; margin-right: auto; }
.cv-node-head b { overflow: hidden; font-size: 13.5px; font-weight: 600; line-height: 1.125rem; text-overflow: ellipsis; white-space: nowrap; }
.cv-node-head small { overflow: hidden; color: var(--cv-subtle); font-size: 12px; line-height: 1rem; text-overflow: ellipsis; white-space: nowrap; }
.cv-x { display: grid; place-items: center; width: 1.625rem; height: 1.625rem; padding: 0; border: 0; border-radius: 7px; background: none; color: var(--cv-subtle); cursor: pointer; opacity: 0; transition: opacity 120ms ease; }
.cv-node:hover .cv-x, .cv-run .cv-x { opacity: 1; }
.cv-x:hover { background: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08)); color: var(--cv-text); }
.cv-x svg { width: 0.8125rem; height: 0.8125rem; }
.cv-node-body { display: grid; gap: 0.75rem; padding: 0.875rem 0.875rem 0.875rem; border-top: 1px solid var(--cv-line); }
.cv-field { display: grid; gap: 0.375rem; min-width: 0; }
.cv-field > label { color: var(--cv-subtle); font-size: 12px; font-weight: 500; }
.cv-field > label em { color: var(--cv-brand-text); font-style: normal; }
.cv-root .cv-input {
width: 100%; height: 2rem; margin: 0; padding: 0 0.625rem; border: 1px solid var(--cv-input-line); border-radius: 8px;
background: var(--cv-input-bg); color: var(--cv-text); font: inherit; font-size: 12.5px; outline: none; box-shadow: none;
}
.cv-root .cv-input::placeholder { color: var(--cv-placeholder); }
.cv-root .cv-input:focus, .cv-root .cv-input:focus-visible {
border-color: color-mix(in srgb, var(--cv-brand) 60%, transparent) !important;
outline: none !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cv-brand) 14%, transparent) !important;
}
.cv-root select.cv-input { padding-right: 1.75rem; appearance: none; background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%238a8a8a%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m6%209%206%206%206-6%22%2F%3E%3C%2Fsvg%3E"); background-repeat: no-repeat; background-position: right 0.5rem center; background-size: 0.875rem; }
.cv-root textarea.cv-input { height: auto; min-height: 4.25rem; padding: 0.4375rem 0.625rem; line-height: 1.3rem; resize: vertical; }
.cv-root .cv-input.mono, .cv-root textarea.cv-input { font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; }
.cv-seg { display: flex; gap: 2px; padding: 2px; border-radius: 8px; background: light-dark(#f4f4f5, rgb(255 255 255 / 0.05)); }
.cv-seg button { flex: 1; height: 1.625rem; padding: 0 0.5rem; border: 0; border-radius: 6px; background: none; color: var(--cv-subtle); font-size: 12px; font-weight: 500; cursor: pointer; }
.cv-seg button.on { background: light-dark(#fff, rgb(255 255 255 / 0.09)); color: var(--cv-text); box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), 0 0 0 1px var(--cv-line); }
.cv-note { margin: 0; color: var(--cv-subtle); font-size: 11.5px; line-height: 1.4; }
.cv-note.bad { color: light-dark(#dc2626, #f87171); }
.cv-status { display: flex; gap: 0.5rem; align-items: flex-start; padding: 0.625rem 0.875rem; border-top: 1px solid var(--cv-line); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; }
.cv-status::before { content: ''; flex: none; width: 0.5rem; height: 0.5rem; margin-top: 0.25rem; border-radius: 50%; background: var(--st); }
.cv-status.running { --st: var(--cv-brand); }
.cv-status.done { --st: #10b981; }
.cv-status.failed { --st: #ef4444; color: light-dark(#dc2626, #f87171); }
/* Ports: filled dots on the header line, like the environment canvas. */
.cv-port {
position: absolute; top: calc(1.625rem - 0.375rem); width: 0.75rem; height: 0.75rem; border-radius: 50%;
border: 2px solid var(--cv-card); background: light-dark(#a3a3a3, #737373); z-index: 2;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
transition: transform 120ms ease, background-color 120ms ease;
}
.cv-port.out { right: -0.375rem; cursor: crosshair; }
.cv-port.in { left: -0.375rem; }
.cv-port.out:hover, .cv-port.out.armed { background: var(--cv-brand); transform: scale(1.2); }
/* Collapsed steps: icons only. */
.cv-root { transition: grid-template-columns 260ms cubic-bezier(0.4, 0, 0.2, 1); }
.cv-root.cv-folded { grid-template-columns: 3.75rem minmax(0, 1fr); }
.cv-palette-head { display: flex; align-items: flex-start; gap: 0.5rem; }
.cv-palette-head > div { min-width: 0; margin-right: auto; }
.cv-fold { opacity: 1; margin-top: 0.0625rem; }
.cv-fold svg { width: 1rem; height: 1rem; }
.cv-folded .cv-palette { padding-inline: 0.5rem; overflow-x: hidden; }
.cv-folded .cv-palette-head { justify-content: center; margin-inline: -0.5rem; padding-inline: 0; }
/* Only the step list folds; the "Add the next step" menu keeps its names. */
.cv-folded .cv-palette-head > div, .cv-folded .cv-palette .cv-item > span { display: none; }
.cv-folded .cv-fold svg { transform: scaleX(-1); }
.cv-folded .cv-palette h4 { height: 1px; margin: 0.5rem 0.375rem; overflow: hidden; color: transparent; background: var(--cv-line); }
.cv-folded .cv-palette .cv-item { justify-content: center; padding: 0.25rem; }
/* Our dropdown */
.cv-root .cv-select { display: flex; align-items: center; gap: 0.5rem; text-align: left; cursor: pointer; }
.cv-root .cv-select > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cv-root .cv-select.empty > span { color: var(--cv-placeholder); }
.cv-root .cv-select > svg { width: 0.875rem; height: 0.875rem; color: var(--cv-subtle); transition: transform 180ms ease; }
.cv-root .cv-select.open > svg { transform: rotate(180deg); }
.cv-root .cv-select.open { border-color: color-mix(in srgb, var(--cv-brand) 60%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cv-brand) 14%, transparent); }
.cv-root .cv-select:disabled { cursor: default; opacity: 0.7; }
.cv-dd {
position: fixed; z-index: 60; display: flex; flex-direction: column; max-height: 17rem; padding: 0.3125rem;
border: 1px solid var(--cv-line); border-radius: 10px; background: var(--cv-popover);
box-shadow: 0 16px 40px -14px rgb(0 0 0 / 0.4); animation: cv-dd-in 120ms ease-out;
}
@keyframes cv-dd-in { from { opacity: 0; transform: translateY(-3px); } }
.cv-dd-search {
flex: none; height: 1.875rem; margin-bottom: 0.25rem; padding: 0 0.625rem; border: 1px solid var(--cv-input-line); border-radius: 7px;
background: var(--cv-input-bg); color: var(--cv-text); font: inherit; font-size: 12.5px; outline: none;
}
.cv-dd-search:focus { border-color: color-mix(in srgb, var(--cv-brand) 60%, transparent); }
.cv-dd-list { overflow-x: hidden; overflow-y: auto; display: grid; grid-template-columns: minmax(0, 1fr); gap: 1px; }
.cv-dd-opt {
display: flex; align-items: center; gap: 0.5rem; width: 100%; min-height: 2rem; padding: 0 0.5rem 0 0.625rem;
border: 0; border-radius: 7px; background: none; color: var(--cv-text); font-size: 12.5px; text-align: left; cursor: pointer;
}
.cv-dd-opt > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cv-dd-opt > svg { width: 0.875rem; height: 0.875rem; color: var(--cv-brand-text); opacity: 0; }
.cv-dd-opt:focus, .cv-dd-opt:focus-visible { outline: none; box-shadow: none; }
.cv-dd-opt.on { font-weight: 600; }
.cv-badge { flex: none; padding: 0.0625rem 0.4375rem; border: 1px solid light-dark(rgb(217 119 6 / 0.3), rgb(251 191 36 / 0.3)); border-radius: 999px; background: light-dark(rgb(217 119 6 / 0.1), rgb(251 191 36 / 0.12)); color: light-dark(#b45309, #fbbf24); font-size: 11px; font-style: normal; font-weight: 500; line-height: 1.4; }
.cv-dd-opt.on > svg { opacity: 1; }
.cv-dd-opt.active { background: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.06)); }
.cv-dd-none { margin: 0; padding: 0.5rem 0.625rem; color: var(--cv-subtle); font-size: 12px; }
.cv-dd-add { flex: none; margin-top: 0.25rem; padding-top: 0.25rem; border-top: 1px solid var(--cv-line); }
.cv-dd-opt.cv-dd-new { color: var(--cv-brand-text); font-weight: 500; }
.cv-dd-opt.cv-dd-new > svg { opacity: 1; color: inherit; }
.cv-dd-form { display: flex; gap: 0.375rem; }
.cv-dd-form .cv-dd-search { flex: 1; min-width: 0; margin: 0; }
.cv-dd-form .button { height: 1.875rem; }
.cv-dd-add .cv-note:not(:empty) { padding: 0.375rem 0.25rem 0.125rem; }
/* Result pill after Deploy: the live address(es). */
.cv-result {
position: absolute; display: flex; align-items: center; gap: 0.25rem; height: 2.375rem; padding: 0 0.25rem 0 0.875rem;
border: 1px solid color-mix(in srgb, #10b981 45%, transparent); border-radius: 999px;
background: var(--cv-card); box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.25); white-space: nowrap;
}
.cv-result > i { width: 0.5rem; height: 0.5rem; margin-right: 0.25rem; border-radius: 50%; background: #10b981; box-shadow: 0 0 0 4px color-mix(in srgb, #10b981 18%, transparent); }
.cv-result a { display: inline-flex; align-items: center; gap: 0.375rem; height: 1.75rem; padding: 0 0.625rem; border-radius: 999px; color: var(--cv-text); font-weight: 600; text-decoration: none; }
.cv-result a:hover { background: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07)); }
.cv-result a svg { width: 0.8125rem; height: 0.8125rem; color: var(--cv-subtle); }
.cv-result .cv-x { opacity: 1; }
.cv-result .cv-port.in { top: calc(50% - 0.375rem); }
/* Next-step menu, toasts */
.cv-menu { position: absolute; z-index: 20; width: 15rem; padding: 0.375rem; border: 1px solid var(--cv-line); border-radius: 12px; background: var(--cv-island); box-shadow: 0 16px 40px -16px rgb(0 0 0 / 0.4); }
.cv-menu h5 { margin: 0.25rem 0.5rem 0.375rem; color: var(--cv-subtle); font-size: 11.5px; font-weight: 500; }
.cv-menu .cv-item { cursor: pointer; }
.cv-toast { position: absolute; left: 50%; bottom: 1rem; z-index: 30; max-width: 80%; transform: translateX(-50%); padding: 0.5rem 0.875rem; border: 1px solid var(--cv-line); border-radius: 8px; background: var(--cv-island); color: var(--cv-text); font-size: 12px; pointer-events: none; box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); }
.cv-toast.bad { border-color: light-dark(#fecaca, rgb(239 68 68 / 0.3)); color: light-dark(#b91c1c, #fca5a5); }
/* Blank canvas: the Start ring pulses and a dashed trail shows where to drag it. */
.cv-node.blank .cv-port.out { background: var(--cv-brand); }
.cv-node.blank .cv-port.out::after { content: ''; position: absolute; inset: -2px; border-radius: 50%; border: 2px solid var(--cv-brand); animation: cv-breathe 1.8s ease-out infinite; pointer-events: none; }
.cv-hint { position: absolute; left: calc(100% + 0.625rem); top: 1.625rem; transform: translateY(-50%); display: flex; align-items: center; gap: 0.5rem; color: var(--cv-brand-text); font-size: 12px; font-weight: 500; white-space: nowrap; pointer-events: none; }
.cv-hint::before { content: ''; width: 4.5rem; border-top: 2px dashed currentColor; opacity: 0.7; animation: cv-hint-grow 1.8s ease-in-out infinite; transform-origin: left; }
.cv-hint span { animation: cv-hint-fade 1.8s ease-in-out infinite; }
.cv-node:has(.cv-port.armed) .cv-hint, .cv-node:has(.cv-port.armed) .cv-port.out::after { display: none; }
@keyframes cv-hint-grow { 0% { transform: scaleX(0); } 60%, 100% { transform: scaleX(1); } }
@keyframes cv-hint-fade { 0%, 30% { opacity: 0; } 60%, 100% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .cv-node.blank .cv-port.out::after, .cv-hint::before, .cv-hint span { animation: none; } }
/* Run panel: an island with the steps as status lights, then the logs. */
.cv-run { position: absolute; top: 0.75rem; right: 0.75rem; bottom: 0.75rem; z-index: 10; display: flex; flex-direction: column; width: min(26rem, 45%); overflow: hidden; border: 1px solid var(--cv-line); border-radius: 12px; background: var(--cv-island); box-shadow: 0 20px 48px -20px rgb(0 0 0 / 0.4); }
.cv-run[hidden] { display: none; }
.cv-run header { display: flex; align-items: flex-start; gap: 0.5rem; padding: 1rem 0.75rem 0.875rem 1.25rem; border-bottom: 1px solid var(--cv-line); }
.cv-run header div { display: grid; gap: 0.0625rem; margin-right: auto; }
.cv-run header b { font-size: 14px; font-weight: 600; }
.cv-run header span { color: var(--cv-subtle); font-size: 12.5px; }
.cv-steps { flex: none; max-height: 40%; overflow-y: auto; display: grid; gap: 0.25rem; padding: 0.75rem 1rem 0.875rem; border-bottom: 1px solid var(--cv-line); }
.cv-step { display: grid; grid-template-columns: 1.25rem minmax(0, 1fr); align-items: start; font-size: 12.5px; line-height: 1.25rem; }
.cv-step i { position: relative; width: 0.5rem; height: 0.5rem; margin: 0.375rem 0 0 0.125rem; border-radius: 50%; background: transparent; box-shadow: inset 0 0 0 1.5px light-dark(#c4c4c4, #4a4a4a); }
.cv-step.running i { background: var(--cv-brand); box-shadow: none; }
.cv-step.running i::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: var(--cv-brand); animation: cv-breathe 1.8s ease-out infinite; }
@keyframes cv-breathe { 0% { transform: scale(1); opacity: 0.5; } 80%, 100% { transform: scale(2.6); opacity: 0; } }
.cv-step.done i { background: #10b981; box-shadow: none; }
.cv-step.failed i { background: #ef4444; box-shadow: none; }
.cv-step small { display: block; color: var(--cv-subtle); font-size: 11.5px; line-height: 1rem; overflow-wrap: anywhere; }
.cv-step.failed small { color: light-dark(#dc2626, #f87171); }
.cv-tabs { flex: none; margin: 0.75rem 0.75rem 0; overflow-x: auto; scrollbar-width: none; }
.cv-tabs[hidden] { display: none; }
.cv-tabs button { flex: 0 1 auto; display: inline-flex; align-items: center; gap: 0.375rem; min-width: 0; max-width: 13rem; }
.cv-tabs button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cv-tabs i { flex: none; width: 0.4375rem; height: 0.4375rem; border-radius: 50%; box-shadow: inset 0 0 0 1.5px light-dark(#c4c4c4, #4a4a4a); }
.cv-tabs button:first-child { flex: none; }
.cv-tabs button:first-child i { display: none; }
.cv-tabs i.running { background: var(--cv-brand); box-shadow: none; animation: cv-blink 1.2s ease-in-out infinite; }
.cv-tabs i.done { background: #10b981; box-shadow: none; }
.cv-tabs i.failed { background: #ef4444; box-shadow: none; }
@keyframes cv-blink { 50% { opacity: 0.35; } }
.cv-logs-wrap { flex: 1; min-height: 0; padding: 0.75rem; display: flex; }
.cv-logs[hidden] { display: none; }
.cv-logs { flex: 1; min-height: 0; margin: 0; padding: 0.75rem 0.875rem; overflow: auto; border: 1px solid var(--cv-input-line); border-radius: 10px; background: var(--cv-input-bg); font: 11.5px/1.6 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); white-space: pre-wrap; overflow-wrap: anywhere; }
.cv-logs:empty::before { content: 'Logs show here while it deploys.'; color: var(--cv-subtle); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); }
.cv-logs .err { color: light-dark(#dc2626, #f87171); }
.cv-logs .dim { color: var(--cv-subtle); }
.cv-run footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0 0.75rem 0.75rem; }
.cv-run footer a[hidden] { display: none; }
/* Small screens: one column. The steps are a strip above the canvas; swipe it sideways,
tap a step to add it to the chain, or drag it down onto the canvas. */
@media (max-width: 767px) {
.cv-root, .cv-root.cv-folded { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr); }
.cv-root:has(> .cv-notice) { grid-template-rows: auto auto auto minmax(0, 1fr); }
.cv-root .cv-palette, .cv-root.cv-folded .cv-palette { flex-direction: row; align-items: center; gap: 0.25rem; align-self: stretch; max-height: none; padding: 0.375rem; overflow: auto hidden; }
.cv-palette-head, .cv-palette h4, .cv-root.cv-folded .cv-palette h4 { display: none; }
.cv-root .cv-palette .cv-item, .cv-root.cv-folded .cv-palette .cv-item { flex: none; width: auto; justify-content: flex-start; padding: 0.25rem 0.625rem 0.25rem 0.25rem; touch-action: pan-x; }
.cv-root.cv-folded .cv-palette .cv-item > span { display: block; }
.cv-item small { display: none; }
.cv-run { left: 0.75rem; width: auto; }
}
`;
export function injectStyles() {
if (document.getElementById('deploy-canvas-style')) return;
const style = document.createElement('style');
style.id = 'deploy-canvas-style';
style.textContent = CSS;
document.head.append(style);
}
@@ -0,0 +1,40 @@
<script>
// Searchable list of cards, each with an Add button (connect view side panels).
import Icon from './Icon.svelte';
let { title, placeholder, empty, candidates, describe, onadd } = $props();
let query = $state('');
let busy = $state({});
const search = $derived(query.trim().toLowerCase());
async function add(candidate) {
busy[candidate.id] = true;
await onadd(candidate);
busy[candidate.id] = false;
}
</script>
<section class="border-t border-neutral-100 px-4 py-3 dark:border-white/[0.06]">
<h3 class="mb-1.5 text-[11px]! leading-4! font-medium! text-neutral-400 dark:text-fg-faint">{title}</h3>
<div class="flex flex-col gap-2">
{#if candidates.length}
<input bind:value={query} class="input h-8 w-full text-[12px]" type="search" {placeholder} aria-label={placeholder} />
{/if}
<ul class="flex flex-col gap-1.5">
{#if !candidates.length}<li class="text-[12px] text-neutral-400 dark:text-fg-faint">{empty}</li>{/if}
{#each candidates as candidate (candidate.id)}
{@const { title: addTitle, sub } = describe(candidate)}
<li class="flex items-center gap-1" class:hidden={!!search && !`${candidate.name} ${sub}`.toLowerCase().includes(search)}>
<div class="flex min-w-0 flex-1 items-center gap-2 px-1.5 py-1 text-[12px]">
<Icon name={candidate.type} class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<div class="min-w-0 flex-1">
<div class="truncate {candidate.type === 'variable' ? 'font-mono' : ''}">{candidate.name}</div>
{#if sub}<div class="truncate text-[11px] text-neutral-400 dark:text-fg-faint">{sub}</div>{/if}
</div>
</div>
<button type="button" class="button h-6! shrink-0 px-2! text-[11px]!" title={addTitle} disabled={busy[candidate.id]} onclick={() => add(candidate)}>Add</button>
</li>
{/each}
</ul>
</div>
</section>
+116
View File
@@ -0,0 +1,116 @@
<script>
// A resource card (or, connect view, a shared variable / tag card).
import { isSource, MAX_CHILDREN, MAX_VOLUMES, statusLabel, TYPE_LABELS } from '../../environment-canvas.js';
import NodeIcon from './NodeIcon.svelte';
import StatusDot from './StatusDot.svelte';
let { node, position, selected, dropTarget, movable, port, linkCount, zoneNames, onpress, onconnect, onselect, onhover } = $props();
let card;
let portDot = $state();
// Same as the deploy canvas drop target: accent border and a soft 4px ring.
const DROP_TARGET = 'border-coollabs! dark:border-warning! ring-4 ring-coollabs/15 dark:ring-warning/15';
const extra = $derived(node.extra_zones?.length || 0);
const volumes = $derived(node.volumes || []);
const children = $derived(node.children || []);
const chip = 'inline-flex h-5 max-w-[9rem] items-center truncate rounded-full border border-neutral-200 bg-neutral-50 px-1.5 text-[10px] font-medium text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.05] dark:text-fg-dim';
const stop = e => e.stopPropagation();
</script>
<!-- data-ec-open: its page opens on double-click (see the root's pointerdown capture listener). -->
<div
bind:this={card}
class="group absolute flex w-[260px] flex-col rounded-xl border bg-white p-3 shadow-sm transition-shadow hover:shadow-md dark:bg-raised {selected
? 'border-coollabs ring-2 ring-coollabs/25 dark:border-warning dark:ring-warning/25'
: node.missing
? 'border-dashed border-red-300 dark:border-red-500/40'
: 'border-neutral-200 hover:border-neutral-300 dark:border-white/[0.08] dark:hover:border-white/[0.16]'} {movable ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'} {dropTarget ? DROP_TARGET : ''}"
style:left={position ? `${position.x}px` : null}
style:top={position ? `${position.y}px` : null}
data-ec-card
data-id={node.id}
data-ec-open={node.link || undefined}
title={node.link ? 'Double-click to open' : undefined}
role="button"
tabindex="0"
aria-label={isSource(node)
? `${node.name} (${node.missing ? 'deleted ' : ''}${TYPE_LABELS[node.type].toLowerCase()})`
: `${node.name} (${TYPE_LABELS[node.type] || node.type}), ${statusLabel(node.status)}`}
onpointerdown={e => onpress(e, card)}
onkeydown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onselect();
}
}}
onpointerenter={() => onhover(true)}
onpointerleave={() => onhover(false)}
>
<div class="flex items-start gap-2.5">
<div class="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border {node.missing
? 'border-red-200 bg-red-50 text-red-500 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400'
: 'border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim'}">
<NodeIcon {node} />
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] leading-4 font-semibold text-black dark:text-fg {node.type === 'variable' ? 'font-mono' : ''} {node.link ? 'underline-offset-2 group-hover:underline' : ''}">{node.name}</div>
<div class="mt-0.5 truncate text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">{node.subtitle || TYPE_LABELS[node.type] || ''}</div>
</div>
{#if isSource(node)}
<span
class="mt-0.5 shrink-0 rounded-full border px-1.5 text-[10px] leading-4 font-medium {node.missing
? 'border-red-200 bg-red-50 text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400'
: 'border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.05] dark:text-fg-dim'}"
title={node.missing ? 'This shared variable was deleted, but resources still reference it' : `Used by ${linkCount} ${linkCount === 1 ? 'resource' : 'resources'}`}
>{node.missing ? 'Deleted' : String(linkCount)}</span>
{:else}
<div class="pt-1"><StatusDot status={node.status} /></div>
{/if}
</div>
{#if node.urls?.length}
<a
class="relative mt-1.5 block max-w-full self-start truncate text-[11px] leading-5 text-neutral-500 hover:text-black hover:underline dark:text-fg-dim dark:hover:text-fg"
href={node.urls[0]}
target="_blank"
rel="noopener noreferrer"
title={node.urls[0]}
onpointerdown={stop}
onclick={stop}
>{node.urls[0].replace(/^https?:\/\//, '')}</a>
{/if}
{#if extra || volumes.length}
<div class="mt-2 flex min-w-0 flex-wrap gap-1 overflow-hidden h-5">
{#if extra}<span class={chip} title={zoneNames.join(', ')}>+{extra} {extra === 1 ? 'server' : 'servers'}</span>{/if}
{#each volumes.slice(0, MAX_VOLUMES) as v, i (i)}<span class={chip} title="Volume {v}">{v}</span>{/each}
{#if volumes.length > MAX_VOLUMES}<span class={chip} title={volumes.slice(MAX_VOLUMES).join(', ')}>+{volumes.length - MAX_VOLUMES}</span>{/if}
</div>
{/if}
{#if children.length}
<ul class="mt-2 border-t border-neutral-100 pt-1.5 dark:border-white/[0.06]">
{#each children.slice(0, MAX_CHILDREN) as child, i (i)}
<li class="flex h-5 items-center gap-2 text-[12px] leading-5">
<StatusDot status={child.status} size="size-1.5" />
<span class="min-w-0 truncate">{child.name}</span>
</li>
{/each}
{#if children.length > MAX_CHILDREN}
<li class="h-5 text-[11px] leading-5 text-neutral-400 dark:text-fg-faint">+{children.length - MAX_CHILDREN} more</li>
{/if}
</ul>
{/if}
<!-- Drag dot: shown on hover only. Connected ends always show (link end dots in the root). -->
{#if port}
<span
bind:this={portDot}
class="absolute top-1/2 -right-1.5 size-3 -translate-y-1/2 cursor-crosshair rounded-full border-2 border-white bg-neutral-400 opacity-0 shadow transition-[opacity,scale,background-color] group-hover:opacity-100 hover:scale-120 hover:bg-coollabs dark:border-raised dark:bg-neutral-500 dark:hover:bg-warning"
data-ec-port
role="presentation"
title={port}
onpointerdown={e => onconnect(e, portDot)}
></span>
{/if}
</div>
@@ -0,0 +1,960 @@
<script>
// Environment canvas (Beta): a read-mostly diagram of one environment.
// Server zones hold resource cards; admins can move cards and draw
// visual-only links. Positions and links are stored through /canvas/api.
//
// The server canvas (data-endpoint) reuses this view. It saves moves and links on the environment canvas of each card.
// The shared variables and tags canvases (data-mode) reuse it too: their links add a variable or a tag to a resource.
import { flushSync, onMount, tick } from 'svelte';
import { api, BOX_GAP, freeSpot, MIN_ZOOM, panZoom } from '../../canvas-core.js';
import {
canConnect,
canLink,
cardRect,
CONNECT_TEXT,
DOUBLE_CLICK_MS,
dragRules,
filterGraph,
filterOptions,
groupByUse,
groupFrames,
isSource,
layoutGraph,
layoutServerView,
linkPath,
MOBILE_NOTICE_KEY,
readView,
STATUS_CHANNELS,
zoneBoxes,
zoneGroups,
} from '../../environment-canvas.js';
import Card from './Card.svelte';
import Icon from './Icon.svelte';
import Panel from './Panel.svelte';
import Toolbar from './Toolbar.svelte';
import Zone from './Zone.svelte';
// The page's [data-environment-canvas] element; it does not change while the canvas is mounted.
let { container } = $props();
// svelte-ignore state_referenced_locally
const ds = container.dataset;
const connectView = ds.mode === 'variables' || ds.mode === 'tags';
const text = CONNECT_TEXT[ds.mode] || CONNECT_TEXT.variables;
const params = new URLSearchParams(window.location.search);
const serverView = !!ds.endpoint && !connectView;
const filterView = connectView || serverView;
const base = ds.endpoint || `/projects/${encodeURIComponent(ds.project || '')}/environments/${encodeURIComponent(ds.environment || '')}/canvas`;
// Table / grid / canvas switcher from the page (Blade), moved into the toolbar.
// svelte-ignore state_referenced_locally
const views = container.querySelector('[data-ec-views]');
const viewOnlyTitle = connectView
? text.viewOnly
: ds.viewOnly ? ds.viewOnly
: serverView
? 'The server view is read-only. Move cards and edit links on an environment canvas.'
: 'You can look around. Only team admins can move cards and edit links.';
// ── State ──
let full = $state.raw(null); // the loaded graph; graph is it after the project / environment filter
let graph = $state.raw(null);
let positions = $state({});
let sizes = $state.raw({});
let selected = $state.raw(null); // { kind: 'node' | 'link', id }
let filter = $state.raw(filterView ? { project: params.get('project') || '', environment: params.get('environment') || '' } : {});
let hover = $state(null);
let pending = $state.raw([]); // links being saved: { source, target }, drawn as moving dashes until the request ends
let temp = $state.raw(null); // the link being drawn: { from, to }
let dropTarget = $state(null);
let loading = $state(false);
let error = $state(null);
let toastMessage = $state.raw(null);
let zoom = $state(1);
let panelTop = $state('');
let showNotice = $state(noticeOpen());
// Plain bookkeeping (not rendered).
let view = { x: 40, y: 40, z: 1 };
const interactions = new Set(); // active drags; refetches wait until they end
let deferred = null;
const unsaved = {};
let offsets = {}; // server view: card shift from its environment canvas position (see layoutServerView)
let reloadQueued = false;
let fitted = false;
let destroyed = false;
let containers = null; // server canvas: unmanaged container cards, loaded after the canvas renders
const timers = { save: 0, refresh: 0, toast: 0, view: 0 };
// Zoom and pan are kept per team and page, so a reload opens the canvas where it was.
const viewKey = `coolify-canvas-view:${ds.team || ''}:${window.location.pathname}`;
let restoredView = readView(viewKey);
if (restoredView) view = restoredView;
let rootEl;
let bodyEl;
let barSlot;
let viewport;
let bar;
let pz;
const canEdit = $derived(!!graph?.can_edit);
const canMove = $derived(canEdit && graph?.can_move !== false);
// Server view: unmanaged containers have no environment canvas, and zone frames are laid out, not saved.
const canMoveNode = node => canMove && (!serverView || !!node?.environment);
const canMoveZones = $derived(canMove && !serverView);
// Environment canvas endpoints of a card (the server view has no endpoints of its own for changes).
const environmentBase = node => (serverView
? `/projects/${encodeURIComponent(node?.project?.uuid || '')}/environments/${encodeURIComponent(node?.environment?.uuid || '')}/canvas`
: base);
const nodeById = id => graph?.nodes.find(n => n.id === id);
const linkById = id => graph?.links.find(l => l.id === id);
const rectOf = id => {
const node = nodeById(id);
return node ? cardRect(node, positions, sizes) : null;
};
const zoneNames = node => {
const names = new Map((graph?.zones || []).map(z => [z.id, z.name]));
return [node.zone, ...(node.extra_zones || [])].filter(Boolean).map(id => names.get(id) || id);
};
const toBox = r => ({ x: r.x, y: r.y, w: r.width, h: r.height });
// ── Derived view data ──
const projects = $derived(filterView ? filterOptions(full) : []);
const environments = $derived(projects
.filter(p => !filter.project || p.uuid === filter.project)
.flatMap(p => p.environments.map(e => ({ uuid: e.uuid, name: filter.project ? e.name : `${p.name} / ${e.name}` }))));
const boxes = $derived(graph ? zoneBoxes(graph, positions, sizes) : []);
// Server and connect views: project / environment frames inside the zones.
const frames = $derived(graph?.layout === 'server' || graph?.layout === 'connect'
? zoneGroups(graph).flatMap(zone => groupFrames(zone.nodes.filter(n => n.context), positions, sizes))
: []);
// Connect view: links per source card (its "used by" badge).
const linkCounts = $derived.by(() => {
const counts = {};
for (const l of graph?.links || []) counts[l.source] = (counts[l.source] || 0) + 1;
return counts;
});
const shapes = $derived.by(() => {
const out = [];
for (const link of graph?.links || []) {
const a = rectOf(link.source);
const b = rectOf(link.target);
if (!a || !b) continue;
const isSelected = selected?.kind === 'link' && selected.id === link.id;
const related = hover && (link.source === hover || link.target === hover);
out.push({
link,
...linkPath(a, b),
selected: isSelected,
faded: hover && !related,
stroke: isSelected ? 'fill-none stroke-coollabs dark:stroke-warning'
: link.missing ? `ec-missing fill-none stroke-red-400 dark:stroke-red-400/70 ${hover && !related ? 'opacity-20' : ''}`
: related ? 'fill-none stroke-neutral-700 dark:stroke-white/80'
: hover ? 'fill-none stroke-neutral-400 opacity-20 dark:stroke-white/40'
: 'fill-none stroke-neutral-400 dark:stroke-white/35',
title: connectView
? `${nodeById(link.source)?.name || ''} → ${nodeById(link.target)?.name || ''} (click to open)`
: link.label ? `${link.label} (click to open)` : 'Click to open this link',
});
}
return out;
});
const pendingPaths = $derived(pending.map(p => [rectOf(p.source), rectOf(p.target)]).filter(([a, b]) => a && b).map(([a, b]) => linkPath(a, b).d));
const tempPath = $derived.by(() => {
if (!temp) return null;
const { from, to } = temp;
const dx = Math.max(40, Math.abs(to.x - from.x) / 2);
return `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`;
});
const panelNode = $derived(selected?.kind === 'node' ? nodeById(selected.id) : null);
const panelLink = $derived(selected?.kind === 'link' ? linkById(selected.id) : null);
const empty = $derived(!!graph && !graph.nodes.length);
// ── Mobile notice ──
// 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).
function noticeOpen() {
try {
return !JSON.parse(localStorage.getItem(MOBILE_NOTICE_KEY));
} catch {
return true; // Storage blocked: show the notice.
}
}
function closeNotice() {
try {
localStorage.setItem(MOBILE_NOTICE_KEY, 'true');
} catch {
// Storage blocked: closed until the next page load.
}
showNotice = false;
}
// ── Toolbar placement ──
const desktop = window.matchMedia('(min-width: 1024px)');
// Floating nav on desktop: a separate panel in the top bar row, centered over the canvas,
// so the top bar keeps only the brand and breadcrumb.
const topbar = document.querySelector('.app-topbar');
const floatingBar = () => desktop.matches && !!topbar && document.documentElement.dataset.navStyle === 'floating';
// Floating nav: the canvas fills the screen behind the top bar and the sidebar (see app.css),
// so fit and the side panel use only the part that the floating panels do not cover.
function coveredInsets() {
if (!floatingBar()) return { top: 0, left: 0 };
const gap = 8;
const v = viewport.getBoundingClientRect();
// The main sidebar, and a page's fixed settings rail (server canvas) when it is on the left.
const rail = document.querySelector('.application-settings-navigation');
const panels = [document.querySelector('.app-sidebar > div > nav'), rail && getComputedStyle(rail).position === 'fixed' ? rail : null]
.map(el => el?.getBoundingClientRect())
.filter(r => r?.width);
// The toolbar is in the top bar row, or in a row below it when the top bar has no room (see alignFloatingBar).
const barBottom = bar?.isConnected ? bar.getBoundingClientRect().bottom : 0;
return {
top: Math.max(0, Math.max(topbar.getBoundingClientRect().bottom, barBottom) + gap - v.top),
left: Math.max(0, ...panels.map(r => r.right + gap - v.left)),
};
}
function alignFloatingBar() {
if (!floatingBar()) {
panelTop = '';
return;
}
const gap = 8;
const area = bodyEl.getBoundingClientRect();
const head = topbar.getBoundingClientRect();
const insetLeft = coveredInsets().left;
// Measure the full toolbar width, then use the top bar row only when it fits next to the top bar
// (a page can dock wide actions in the top bar, e.g. the server canvas).
Object.assign(bar.style, { left: '0px', maxWidth: '' });
const width = bar.offsetWidth;
const besideHead = head.right + gap + width <= window.innerWidth - gap;
const left = Math.max(area.left + insetLeft + (area.width - insetLeft - width) / 2, besideHead ? head.right + gap : area.left + insetLeft);
const top = besideHead ? head.top : head.bottom + gap;
Object.assign(bar.style, { top: `${top}px`, left: `${left}px`, minHeight: `${head.height}px`, maxWidth: `${window.innerWidth - left - gap}px` });
panelTop = `${coveredInsets().top}px`;
}
function placeBar() {
// data-toolbar-inline: the page docks its own actions in the top bar, so the toolbar stays in the canvas.
const slot = desktop.matches && !floatingBar() && !('toolbarInline' in ds) && document.getElementById('page-toolbar-slot');
bar.removeAttribute('style');
if (floatingBar()) {
bar.className = 'fixed z-40 flex flex-wrap items-center justify-center gap-3 rounded-xl border border-neutral-200 bg-white px-2 py-1.5 dark:border-white/[0.08] dark:bg-panel';
bodyEl.append(bar);
alignFloatingBar();
} else if (slot) {
bar.className = 'flex items-center gap-3 pl-3';
slot.append(bar);
} else {
bar.className = 'flex shrink-0 flex-wrap items-center gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 dark:border-white/[0.08] dark:bg-panel';
barSlot.append(bar);
panelTop = '';
}
}
/** Action: the toolbar element; onMount docks it (top bar slot, floating panel, or above the canvas). */
function dock(node) {
bar = node;
// The toolbar can live outside the canvas root (top bar slot), so remove it here.
return { destroy: () => node.remove() };
}
function watchToolbar() {
placeBar();
desktop.addEventListener('change', placeBar);
window.addEventListener('resize', alignFloatingBar);
const navStyleObserver = new MutationObserver(placeBar);
navStyleObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-nav-style'] });
// Sidebar collapse, breadcrumb changes and toolbar content change the sizes.
const resizeObserver = new ResizeObserver(alignFloatingBar);
for (const el of [bodyEl, bar, topbar].filter(Boolean)) resizeObserver.observe(el);
return () => {
desktop.removeEventListener('change', placeBar);
window.removeEventListener('resize', alignFloatingBar);
navStyleObserver.disconnect();
resizeObserver.disconnect();
};
}
// ── Toast ──
function toast(message, bad = false) {
toastMessage = { text: message, bad };
clearTimeout(timers.toast);
timers.toast = setTimeout(() => (toastMessage = null), bad ? 5000 : 2600);
}
// ── View ──
/** Action: wheel / pinch zoom and drag-to-pan on the viewport (canvas-core panZoom). */
function panZoomAction(node) {
pz = panZoom({
root: node.closest('[data-ec-root]'),
viewport: node,
world: node.firstElementChild,
zoomLabel: {}, // the toolbar shows `zoom`
getView: () => view,
onChange: () => {
zoom = view.z;
clearTimeout(timers.view);
timers.view = setTimeout(saveView, 300);
},
canPan: e => !e.target.closest('[data-ec-card],[data-ec-zone-header],[data-ec-label],[data-ec-link],a,button,input'),
onPanEnd: moved => {
if (!moved) closePanel();
},
});
}
function saveView() {
clearTimeout(timers.view);
try {
localStorage.setItem(viewKey, JSON.stringify(view));
} catch {
// Storage full or blocked: the canvas fits to the screen next time.
}
}
function fit() {
const all = graph ? zoneBoxes(graph, positions, sizes) : [];
const r = viewport.getBoundingClientRect();
const inset = coveredInsets();
if (!all.length || !r.width) {
view = { x: inset.left + 40, y: inset.top + 40, z: 1 };
pz.apply();
return;
}
const minX = Math.min(...all.map(b => b.x));
const minY = Math.min(...all.map(b => b.y));
const maxX = Math.max(...all.map(b => b.x + b.width));
const maxY = Math.max(...all.map(b => b.y + b.height));
const pad = 48;
const width = r.width - inset.left;
const height = r.height - inset.top;
const z = Math.min(1.1, Math.max(MIN_ZOOM, Math.min((width - pad * 2) / (maxX - minX), (height - pad * 2) / (maxY - minY))));
view = { z, x: inset.left + (width - (maxX - minX) * z) / 2 - minX * z, y: inset.top + (height - (maxY - minY) * z) / 2 - minY * z };
pz.apply();
}
function setFilter(next) {
filter = next;
const url = new URL(window.location.href);
for (const key of ['project', 'environment']) {
if (next[key]) url.searchParams.set(key, next[key]);
else url.searchParams.delete(key);
}
window.history.replaceState(window.history.state, '', url);
fitted = false;
applyGraph(full);
}
function measure() {
const next = {};
for (const el of rootEl.querySelectorAll('[data-ec-card]')) {
if (el.offsetWidth) next[el.dataset.id] = { width: el.offsetWidth, height: el.offsetHeight };
}
sizes = next;
}
// ── Selection ──
function select(selection) {
selected = selection;
}
function closePanel() {
if (selected) select(null);
}
/** Changes the shown graph; the loaded graph too when no filter made a copy of it. */
function patchGraph(changes) {
const same = full === graph;
graph = { ...graph, ...changes };
if (same) full = graph;
}
function replaceLink(link) {
patchGraph({ links: graph.links.map(l => (l.id === link.id ? link : l)) });
}
async function saveLabel(link, value) {
try {
const updated = await api('PATCH', `${environmentBase(nodeById(link.source))}/links/${encodeURIComponent(link.id)}`, { label: value || null });
replaceLink({ ...link, ...(updated?.id ? updated : { label: value || null }) });
toast('Label saved.');
return true;
} catch (e) {
toast(e.message, true);
return false;
}
}
async function deleteLink(link) {
try {
await api('DELETE', `${environmentBase(nodeById(link.source))}/links/${encodeURIComponent(link.id)}`);
patchGraph({ links: graph.links.filter(l => l.id !== link.id) });
select(null);
toast('Link removed.');
} catch (e) {
toast(e.message, true);
}
}
// Connect view: removes a variable from a resource, or a tag.
async function removeLink(link) {
const variable = nodeById(link.source);
const resource = nodeById(link.target);
try {
await api('DELETE', `${base}/links/${encodeURIComponent(link.id)}`);
if (destroyed) return;
if (selected?.kind === 'link') selected = { kind: 'node', id: link.source };
setLinks(full.links.filter(l => l.id !== link.id));
toast(text.removed(variable?.name, resource?.name));
load({ quiet: true });
} catch (e) {
toast(e.message, true);
}
}
// ── Interactions ──
function begin(key) {
interactions.add(key);
}
function end(key) {
interactions.delete(key);
if (!interactions.size && deferred) {
const next = deferred;
deferred = null;
applyGraph(next);
}
}
function capture(el, pointerId) {
try {
el.setPointerCapture(pointerId);
} catch {
// Synthetic or already released pointer; window-level fallbacks are not needed for real pointers.
}
}
/** Follows a captured pointer on `el` until it is released. */
function track(el, move, up) {
const release = () => {
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', release);
el.removeEventListener('pointercancel', release);
up();
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', release);
el.addEventListener('pointercancel', release);
}
function hoverCard(id, on) {
if (on && !interactions.size) hover = id;
else if (!on && hover === id) hover = null;
}
function startCardDrag(id, card, e) {
if (e.button !== 0 || e.target.closest('a,button,input,[data-ec-port]')) return;
e.stopPropagation();
const start = { cx: e.clientX, cy: e.clientY, ...positions[id] };
// The other cards and frames stay put during the drag, so measure them once.
const rules = dragRules(graph, positions, sizes, id);
let moved = false;
capture(card, e.pointerId);
begin(`card:${id}`);
track(card, ev => {
if (!moved && Math.hypot(ev.clientX - start.cx, ev.clientY - start.cy) < 4) return;
if (!canMoveNode(nodeById(id))) return;
const rect = rectOf(id);
const wanted = { x: start.x + (ev.clientX - start.cx) / view.z, y: start.y + (ev.clientY - start.cy) / view.z, w: rect.width, h: rect.height };
// Cards cannot cover each other or another zone: stop at the edge of what is in the way.
const spot = freeSpot(wanted, rules.others, BOX_GAP, rules.fits);
if (!spot) return;
moved = true;
positions[id] = { x: spot.x, y: spot.y };
}, () => {
if (moved) queueSave({ [id]: { ...positions[id] } });
else select({ kind: 'node', id });
end(`card:${id}`);
});
}
function startZoneDrag(box, header, e) {
if (e.button !== 0 || !canMoveZones || e.target.closest('a')) return;
e.stopPropagation();
const ids = box.nodes.filter(id => positions[id]);
const starts = Object.fromEntries(ids.map(id => [id, { ...positions[id] }]));
const origin = { cx: e.clientX, cy: e.clientY };
// Zones cannot cover each other: the frame stops at the edge of another zone.
const zone = toBox(box);
const others = zoneBoxes(graph, positions, sizes).filter(z => z.id !== box.id).map(toBox);
let moved = false;
capture(header, e.pointerId);
begin('zone');
track(header, ev => {
if (!moved && Math.hypot(ev.clientX - origin.cx, ev.clientY - origin.cy) < 4) return;
const spot = freeSpot({ ...zone, x: zone.x + (ev.clientX - origin.cx) / view.z, y: zone.y + (ev.clientY - origin.cy) / view.z }, others);
if (!spot) return;
moved = true;
const dx = spot.x - zone.x;
const dy = spot.y - zone.y;
for (const id of ids) positions[id] = { x: starts[id].x + dx, y: starts[id].y + dy };
}, () => {
if (moved) queueSave(Object.fromEntries(ids.map(id => [id, { ...positions[id] }])));
end('zone');
});
}
function startConnect(id, port, e) {
if (e.button !== 0 || !canEdit) return;
e.stopPropagation();
e.preventDefault();
const a = rectOf(id);
if (!a) return;
const from = { x: a.x + a.width, y: a.y + a.height / 2 };
capture(port, e.pointerId);
begin('connect');
hover = null;
track(port, ev => {
temp = { from, to: pz.toWorld(ev.clientX, ev.clientY) };
const tid = document.elementFromPoint(ev.clientX, ev.clientY)?.closest?.('[data-ec-card]')?.dataset.id;
dropTarget = tid && canConnect(graph, id, tid) ? tid : null;
}, async () => {
const chosen = dropTarget;
dropTarget = null;
temp = null;
if (chosen) await createLink(id, chosen);
end('connect');
});
}
async function createLink(source, target) {
if (!canLink(graph.links, source, target)) {
toast(connectView ? text.already(nodeById(source)?.name, nodeById(target)?.name) : 'These resources are already linked.', true);
return;
}
const entry = { source, target };
pending = [...pending, entry];
try {
const link = await api('POST', `${environmentBase(nodeById(source))}/links`, { source, target });
if (destroyed) return;
if (connectView) {
// Keep the panel of the card the user added from (either end); otherwise open the source.
const keep = selected?.kind === 'node' && (selected.id === source || selected.id === target);
if (!keep) selected = { kind: 'node', id: source };
if (link?.id) setLinks([...full.links.filter(l => l.id !== link.id), link]);
else applyGraph(full);
toast(text.added(nodeById(source)?.name, nodeById(target)?.name));
load({ quiet: true });
return;
}
const created = link?.data?.id ? link.data : link;
if (created?.id) {
patchGraph({ links: [...graph.links.filter(l => l.id !== created.id), created] });
select({ kind: 'link', id: created.id });
await tick();
rootEl.querySelector('[data-ec-label-input]')?.focus();
} else {
load({ quiet: true });
}
} catch (e) {
toast(e.message, true);
} finally {
pending = pending.filter(p => p !== entry);
}
}
// Double-click on a card opens its page. Tracked on pointerdown (capture phase, before drags start):
// the first click re-renders the card and can open the side panel over it, so the second press can land on
// another element. Two presses at the same point open the page of the first one.
let lastOpen = null;
function openOnDoubleClick(e) {
const own = e.button !== 0 || e.target.closest('a,button,input,[data-ec-port]');
const again = !own && lastOpen && e.timeStamp - lastOpen.at < DOUBLE_CLICK_MS
&& Math.hypot(e.clientX - lastOpen.x, e.clientY - lastOpen.y) < 6;
if (again) {
e.preventDefault();
e.stopPropagation();
window.location.assign(lastOpen.href);
lastOpen = null;
return;
}
const href = own ? null : e.target.closest('[data-ec-open]')?.dataset.ecOpen;
lastOpen = href ? { href, at: e.timeStamp, x: e.clientX, y: e.clientY } : null;
}
// ── Persistence ──
/**
* Server view: environment canvas positions of the moved cards and of the other cards of their group,
* so the whole group keeps its arrangement on the next layout.
*/
function environmentPositions(ids) {
const moved = ids.map(nodeById).filter(n => n?.environment);
const members = (graph?.nodes || []).filter(n => offsets[n.id] && positions[n.id]
&& moved.some(m => m.zone === n.zone && m.environment.uuid === n.environment?.uuid));
return Object.fromEntries(members.map(n => [n.id, { x: positions[n.id].x - offsets[n.id].x, y: positions[n.id].y - offsets[n.id].y }]));
}
function queueSave(map) {
Object.assign(unsaved, serverView ? environmentPositions(Object.keys(map)) : map);
clearTimeout(timers.save);
timers.save = setTimeout(() => flushSave(), 400);
}
async function flushSave(options = {}) {
clearTimeout(timers.save);
const batch = { ...unsaved };
const ids = Object.keys(batch);
if (!ids.length || !graph) return;
const rounded = Object.fromEntries(ids.map(id => [id, { x: Math.round(batch[id].x), y: Math.round(batch[id].y) }]));
const forget = () => {
for (const id of ids) if (unsaved[id] === batch[id]) delete unsaved[id];
};
// Server view: one request per environment canvas.
const requests = new Map();
for (const id of ids) {
const path = environmentBase(full?.nodes.find(n => n.id === id));
requests.set(path, { ...requests.get(path), [id]: rounded[id] });
}
try {
await Promise.all([...requests].map(([path, part]) => api('PATCH', `${path}/positions`, { positions: part }, options)));
if (graph && !destroyed) patchGraph({ positions: { ...(graph.positions || {}), ...rounded } });
forget();
} catch (e) {
forget();
if (destroyed) return;
toast(e.message, true);
load({ quiet: true });
}
}
// ── Data ──
/** Shared variables view: a link change moves its resource in or out of the connected zone right away. */
function setLinks(links) {
applyGraph({ ...full, links });
}
function applyGraph(next) {
if (destroyed) return;
if (interactions.size) {
deferred = next;
return;
}
next.zones ||= [];
next.nodes ||= [];
next.links ||= [];
next.positions = next.positions && !Array.isArray(next.positions) ? next.positions : {};
// Local moves that are not saved yet stay where the user put them.
next.positions = { ...next.positions, ...Object.fromEntries(Object.entries(unsaved).map(([id, p]) => [id, { x: p.x, y: p.y }])) };
full = next;
const shown = groupByUse(filterGraph(next, filter));
offsets = {};
positions = shown.layout === 'server' ? layoutServerView(shown, offsets) : layoutGraph(shown);
graph = shown;
error = null;
if (hover && !nodeById(hover)) hover = null;
if (selected && !(selected.kind === 'node' ? nodeById(selected.id) : linkById(selected.id))) selected = null;
// Render the cards now and measure them, so frames, links and fit use their real sizes.
flushSync();
measure();
flushSync();
if (!fitted) {
fitted = true;
// The first load keeps a saved view; later refits (filter changes) fit again.
if (restoredView) restoredView = null;
else fit();
// Connect view: ?select=<card id> (from a variable's or tag's connect button) opens that card.
const wanted = connectView && params.get('select');
if (wanted && !selected && nodeById(wanted)) select({ kind: 'node', id: wanted });
params.delete('select');
}
}
async function load({ quiet = false } = {}) {
if (destroyed) return;
if (loading) {
reloadQueued = true;
return;
}
loading = true;
try {
const next = await api('GET', base);
if (next && containers) next.nodes = [...(next.nodes || []), ...containers];
applyGraph(next);
if (!quiet && fitted && next) toast('Canvas refreshed.');
// Status refreshes keep the containers; the first load and the Refresh button read them again.
if (next?.containers_endpoint && (!quiet || !containers)) loadContainers(next.containers_endpoint);
} catch (e) {
if (destroyed) return;
if (graph) toast(e.message, true);
else error = e.message;
} finally {
loading = false;
if (!destroyed && reloadQueued) {
reloadQueued = false;
load({ quiet: true });
}
}
}
/** Server canvas: adds the unmanaged containers. This runs a command on the server, so the canvas does not wait for it. */
async function loadContainers(path) {
containers ||= []; // status refreshes during this request do not start another one
try {
const { nodes = [] } = (await api('GET', path)) || {};
if (destroyed || !full) return;
containers = nodes;
applyGraph({ ...full, nodes: [...full.nodes.filter(n => n.type !== 'container'), ...nodes] });
} catch {
// The server zone already shows when the server is not reachable.
}
}
function scheduleRefresh(delay = 1000) {
clearTimeout(timers.refresh);
timers.refresh = setTimeout(() => load({ quiet: true }), delay);
}
function onKeydown(e) {
if (e.key === 'Escape' && selected && !e.defaultPrevented) closePanel();
}
function onVisibility() {
if (document.visibilityState === 'visible') scheduleRefresh(300);
}
function onPageHide() {
saveView();
if (Object.keys(unsaved).length) flushSave({ keepalive: true });
}
onMount(() => {
// Live status: application and service events go to the team channel; database events to the user channel.
const onStatus = () => scheduleRefresh();
const cleanups = [watchToolbar()];
for (const [kind, events] of STATUS_CHANNELS) {
if (!window.Echo || !ds[kind]) continue;
try {
const channel = window.Echo.private(`${kind}.${ds[kind]}`);
for (const event of events) channel.listen(event, onStatus);
cleanups.push(() => {
for (const event of events) channel.stopListening(event, onStatus);
});
} catch {
// Live updates are optional; Refresh still works.
}
}
pz.apply();
load({ quiet: true });
return () => {
saveView();
if (Object.keys(unsaved).length) flushSave({ keepalive: true });
destroyed = true;
clearTimeout(timers.save);
clearTimeout(timers.refresh);
clearTimeout(timers.toast);
clearTimeout(timers.view);
for (const cleanup of cleanups) cleanup();
};
});
</script>
<svelte:document onkeydown={onKeydown} onvisibilitychange={onVisibility} />
<svelte:window onpagehide={onPageHide} />
<!-- Same look as the deploy canvas: a rounded top bar box on the backdrop. -->
<div
bind:this={rootEl}
class="absolute inset-0 z-0 flex flex-col gap-2 overflow-hidden bg-neutral-50 p-2 text-[13px] text-neutral-700 dark:bg-app dark:text-fg-dim"
data-ec-root
onpointerdowncapture={openOnDoubleClick}
>
<!-- The toolbar's place above the canvas; use:dock moves it into the top bar when there is room. -->
<div bind:this={barSlot} class="contents">
<Toolbar
title={ds.title}
viewOnly={!!graph && !canEdit}
{viewOnlyTitle}
{filter}
{projects}
{environments}
{loading}
{zoom}
{views}
{dock}
onfilter={setFilter}
onrefresh={() => load()}
onzoom={z => pz.zoomTo(z)}
onfit={fit}
/>
</div>
{#if showNotice}
<div class="flex shrink-0 items-start gap-2 rounded-xl bg-red-600 py-2.5 pr-2 pl-3 text-[12.5px] leading-[1.125rem] text-white md:hidden" data-ec-mobile-notice role="note">
<Icon name="info" class="size-4 mt-px" />
<p class="min-w-0 flex-1">The canvas is not optimized for mobile yet. It works best on a larger screen.</p>
<button type="button" class="grid size-6.5 shrink-0 -mt-0.75 place-items-center rounded-md hover:bg-white/20" title="Close" aria-label="Close this notice" onclick={closeNotice}>
<Icon name="x" class="size-4" />
</button>
</div>
{/if}
<div bind:this={bodyEl} class="relative min-h-0 flex-1">
<div
bind:this={viewport}
use:panZoomAction
class="absolute inset-0 cursor-grab touch-none overflow-hidden select-none bg-[radial-gradient(circle,rgb(0_0_0/0.13)_1px,transparent_1px)] active:cursor-grabbing dark:bg-[radial-gradient(circle,rgb(255_255_255/0.09)_1px,transparent_1px)]"
data-ec-viewport
>
<div class="absolute top-0 left-0 origin-top-left">
<div class="absolute top-0 left-0">
{#each boxes as box (box.id ?? '__none')}
<Zone {box} movable={canMoveZones} onpress={(e, header) => startZoneDrag(box, header, e)} />
{/each}
</div>
<!-- Server view: project / environment frames inside the server zones. -->
<div class="pointer-events-none absolute top-0 left-0">
{#each frames as frame, i (i)}
<div class="absolute rounded-xl border border-dashed border-neutral-300 dark:border-white/[0.12]" style:left="{frame.x}px" style:top="{frame.y}px" style:width="{frame.width}px" style:height="{frame.height}px">
<div class="flex h-8 items-center gap-1.5 px-3 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
<Icon name="service" class="size-3 text-neutral-400 dark:text-fg-faint" />
<a class="pointer-events-auto truncate hover:text-neutral-700 dark:hover:text-fg [&[href]]:cursor-pointer [&[href]]:underline-offset-2 [&[href]]:hover:underline" href={frame.link || undefined} draggable="false">{frame.name || 'Other'}</a>
</div>
</div>
{/each}
</div>
<svg class="pointer-events-none absolute top-0 left-0 overflow-visible" width="1" height="1">
{#each shapes as shape (shape.link.id)}
<path d={shape.d} stroke-width={shape.selected ? '2.5' : '1.75'} stroke-linecap="round" class="{shape.stroke} transition-opacity" />
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<path
d={shape.d}
fill="none"
stroke="transparent"
stroke-width="14"
pointer-events="stroke"
class="cursor-pointer"
data-ec-link={shape.link.id}
onclick={e => {
e.stopPropagation();
select({ kind: 'link', id: shape.link.id });
}}
><title>{shape.title}</title></path>
{/each}
{#each pendingPaths as d, i (i)}
<path {d} stroke-width="2" stroke-linecap="round" class="ec-temp fill-none stroke-coollabs dark:stroke-warning" />
{/each}
{#if tempPath}
<path d={tempPath} stroke-width="2" stroke-linecap="round" class="ec-temp fill-none stroke-coollabs dark:stroke-warning" />
{/if}
</svg>
<div class="absolute top-0 left-0">
{#each shapes.filter(s => s.link.label) as shape (shape.link.id)}
<button
type="button"
class="absolute max-w-48 -translate-x-1/2 -translate-y-1/2 cursor-pointer truncate rounded-full border px-2 py-0.5 text-[11px] font-medium shadow-sm transition-opacity {shape.selected
? 'border-coollabs bg-white text-coollabs dark:border-warning dark:bg-panel dark:text-warning'
: 'border-neutral-200 bg-white text-neutral-600 dark:border-white/[0.1] dark:bg-panel dark:text-fg-dim'} {shape.faded ? 'opacity-30' : ''}"
style:left="{shape.mid.x}px"
style:top="{shape.mid.y}px"
data-ec-label
onclick={e => {
e.stopPropagation();
select({ kind: 'link', id: shape.link.id });
}}
>{shape.link.label}</button>
{/each}
</div>
<div class="absolute top-0 left-0">
{#each graph?.nodes || [] as node (node.id)}
<Card
{node}
position={positions[node.id]}
selected={selected?.kind === 'node' && selected.id === node.id}
dropTarget={dropTarget === node.id}
movable={canMoveNode(node)}
port={canEdit && (connectView ? isSource(node) && !node.missing : !serverView || !!node.environment)
? (connectView ? text.port : 'Drag to another card to draw a link')
: null}
linkCount={linkCounts[node.id] || 0}
zoneNames={zoneNames(node)}
onpress={(e, card) => startCardDrag(node.id, card, e)}
onconnect={(e, port) => startConnect(node.id, port, e)}
onselect={() => select({ kind: 'node', id: node.id })}
onhover={on => hoverCard(node.id, on)}
/>
{/each}
</div>
<!-- Link end dots sit above the cards, on their edges (like the deploy canvas ports). -->
<div class="pointer-events-none absolute top-0 left-0">
{#each shapes as shape (shape.link.id)}
{#each [shape.from, shape.to] as at, i (i)}
<span
class="absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow dark:border-raised {shape.selected ? 'bg-coollabs dark:bg-warning' : 'bg-neutral-400 dark:bg-neutral-500'} {shape.faded ? 'opacity-30' : ''}"
style:left="{at.x}px"
style:top="{at.y}px"
></span>
{/each}
{/each}
</div>
</div>
</div>
{#if error || empty}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center p-6">
<div class="pointer-events-auto flex max-w-sm flex-col items-center rounded-xl border border-neutral-200 bg-white px-6 py-8 text-center shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div class="mb-3 flex size-9 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<Icon name="service" class="size-4" />
</div>
{#if error}
<p class="text-[13px] font-semibold text-black dark:text-fg">Could not load the canvas</p>
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-faint">{error}</p>
<button type="button" class="button mt-4" onclick={() => load()}>Try again</button>
{:else}
<p class="text-[13px] font-semibold text-black dark:text-fg">
{connectView ? text.empty : ds.empty ? ds.empty : serverView ? 'No resources on this server yet' : 'No resources in this environment yet'}
</p>
{#if ds.new}
<a class="button button-highlighted mt-4" href={ds.new}><Icon name="plus" />Add resource</a>
{/if}
{/if}
</div>
</div>
{/if}
{#if graph && (panelNode || panelLink)}
{#key `${selected.kind}:${selected.id}`}
<Panel
{graph}
node={panelNode}
link={panelLink}
{connectView}
{text}
{canEdit}
top={panelTop}
{nodeById}
{zoneNames}
onselect={select}
onclose={closePanel}
oncreate={createLink}
onremove={removeLink}
onlabel={saveLabel}
ondelete={deleteLink}
/>
{/key}
{/if}
<div class="pointer-events-none absolute bottom-4 left-1/2 z-30 flex -translate-x-1/2 flex-col items-center gap-2">
{#if toastMessage}
<div
class={toastMessage.bad
? 'pointer-events-auto max-w-md rounded-lg border border-red-200 bg-white px-3 py-2 text-[12px] text-red-700 shadow-lg dark:border-red-500/30 dark:bg-panel dark:text-red-300'
: 'pointer-events-auto max-w-md rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[12px] text-neutral-700 shadow-lg dark:border-white/[0.08] dark:bg-panel dark:text-fg'}
role={toastMessage.bad ? 'alert' : 'status'}
>{toastMessage.text}</div>
{/if}
</div>
</div>
</div>
<style>
@keyframes -global-ec-dash {
to {
stroke-dashoffset: -12;
}
}
:global([data-ec-root] .ec-temp) {
stroke-dasharray: 6 6;
animation: ec-dash 0.6s linear infinite;
}
:global([data-ec-root] .ec-missing) {
stroke-dasharray: 5 5;
}
</style>
@@ -0,0 +1,10 @@
<script>
// Outline icon drawn from the local paths (environment-canvas.js ICONS).
import { ICONS } from '../../environment-canvas.js';
let { name, class: cls = 'size-3.5' } = $props();
</script>
<svg class="{cls} shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
{#each ICONS[name] || [] as d (d)}<path {d} />{/each}
</svg>
@@ -0,0 +1,37 @@
<script>
// The logo of a resource card (database engine, service template, build source), or its type icon
// when it has no logo or the logo fails to load. The favicon of an application domain replaces the
// logo once it loads.
import Icon from './Icon.svelte';
let { node, class: cls = 'size-4' } = $props();
const logo = $derived(node.logo);
// Sources that failed to load; a failed logo shows its fallback, a failed fallback shows the type icon.
let failed = $state({});
// The favicon URL that loaded.
let loadedFavicon = $state(null);
const shown = src => (failed[src] && logo.fallback ? logo.fallback : src);
const broken = $derived(!!logo?.src && [logo.src, logo.dark].filter(Boolean).some(src => failed[src] && (!logo.fallback || failed[logo.fallback])));
const faviconShown = $derived(!!logo?.favicon && loadedFavicon === logo.favicon);
const onerror = src => () => {
failed[shown(src) === src ? src : logo.fallback] = true;
};
</script>
{#if faviconShown}
<img class="size-full object-contain p-1" alt="" src={logo.favicon} />
{:else}
{#if !logo?.src || broken}
<Icon name={node.type} class={cls} />
{:else}
<span class="flex size-full items-center justify-center">
<img class="size-full object-contain {logo.padded ? 'p-1' : ''} {logo.dark ? 'dark:hidden' : ''}" alt="" src={shown(logo.src)} onerror={onerror(logo.src)} />
{#if logo.dark}
<img class="size-full object-contain {logo.padded ? 'p-1' : ''} hidden dark:block" alt="" src={shown(logo.dark)} onerror={onerror(logo.dark)} />
{/if}
</span>
{/if}
{#if logo?.favicon && !failed[logo.favicon]}
<img class="hidden" alt="" src={logo.favicon} onload={() => (loadedFavicon = logo.favicon)} onerror={() => (failed[logo.favicon] = true)} />
{/if}
{/if}
@@ -0,0 +1,324 @@
<script>
// Side panel of the selected card or link. Connect view (shared variables, tags): a source card,
// a resource with its sources, or a source → resource link; other views: a card or a diagram link.
import { isSource, NO_SERVER, statusLabel, TYPE_LABELS } from '../../environment-canvas.js';
import AddSection from './AddSection.svelte';
import Icon from './Icon.svelte';
import NodeIcon from './NodeIcon.svelte';
import StatusDot from './StatusDot.svelte';
let { graph, node, link, connectView, text, canEdit, top, nodeById, zoneNames, onselect, onclose, oncreate, onremove, onlabel, ondelete } = $props();
// Requests in flight, per link or card id: their buttons stay disabled until the request ends.
let busy = $state({});
async function run(id, request) {
busy[id] = true;
try {
await request();
} finally {
busy[id] = false;
}
}
// Diagram link label: saved on blur and Enter, Escape restores it. Follows the link while not focused.
let labelInput = $state();
let label = $state('');
let saved = '';
$effect(() => {
const current = link?.label || '';
if (document.activeElement !== labelInput) {
label = current;
saved = current;
}
});
async function saveLabel() {
const value = label.trim();
if (value === saved) return;
saved = value;
if (!(await onlabel(link, value))) saved = link.label || '';
}
const addProps = (title, placeholder, empty, candidates, describe, onadd) => ({ title, placeholder, empty, candidates, describe, onadd });
const links = $derived(graph.links || []);
const removeTitle = l => (l.keys?.length
? `Delete ${l.keys.join(', ')} from ${nodeById(l.target)?.name}`
: `Remove ${nodeById(l.source)?.name} from ${nodeById(l.target)?.name}`);
const removeHint = l => `${l.keys.join(', ')} uses ${nodeById(l.source)?.reference || 'the variable'} inside a longer value. Edit it on the resource.`;
// Connect view lists.
const usedBy = $derived(node ? links.filter(l => l.source === node.id) : []);
const sources = $derived(node ? links.filter(l => l.target === node.id) : []);
const nodeLinks = $derived(node ? links.filter(l => l.source === node.id || l.target === node.id) : []);
const servers = $derived(node ? zoneNames(node) : []);
const rowButton = 'flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1 text-left text-[12px] transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.06]';
const listButton = 'flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-[12px] transition-colors hover:bg-neutral-100 dark:hover:bg-white/[0.06]';
const smallButton = danger => `button h-6! shrink-0 px-2! text-[11px]! ${danger ? 'text-red-600! dark:text-red-400!' : ''}`;
const faint = 'text-[12px] text-neutral-400 dark:text-fg-faint';
const statusPill = 'inline-flex h-6 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 text-xs font-medium text-neutral-700 dark:border-white/[0.12] dark:bg-white/[0.07] dark:text-white';
const titleLink = '[&[href]]:cursor-pointer [&[href]]:underline-offset-2 [&[href]]:hover:underline';
</script>
{#snippet section(title, content)}
<section class="border-t border-neutral-100 px-4 py-3 dark:border-white/[0.06]">
<h3 class="mb-1.5 text-[11px]! leading-4! font-medium! text-neutral-400 dark:text-fg-faint">{title}</h3>
{@render content()}
</section>
{/snippet}
{#snippet note(content)}
<p class="border-t border-neutral-100 px-4 py-3 text-[12px] text-neutral-500 dark:border-white/[0.06] dark:text-fg-faint">{content}</p>
{/snippet}
{#snippet shell(title, iconSource, body, footer)}
<aside class="absolute top-3 right-3 bottom-3 z-20 flex w-80 max-w-[calc(100%-1.5rem)] flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-lg dark:border-white/[0.08] dark:bg-panel" style:top={top || null} data-ec-panel>
<div class="flex items-center gap-2.5 px-4 py-3">
<div class="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
{#if typeof iconSource === 'string'}<Icon name={iconSource} class="size-4" />{:else}<NodeIcon node={iconSource} />{/if}
</div>
<h2 class="min-w-0 flex-1 truncate text-[14px]! leading-5! font-semibold! text-black dark:text-fg">{title}</h2>
<button type="button" class="flex size-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.07] dark:hover:text-fg" title="Close" aria-label="Close panel" onclick={onclose}>
<Icon name="x" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">{@render body()}</div>
{#if footer}<div class="border-t border-neutral-100 p-4 dark:border-white/[0.06]">{@render footer()}</div>{/if}
</aside>
{/snippet}
{#snippet meta(n)}
<div class="flex flex-wrap items-center gap-2 px-4 pb-3">
<span class="text-[12px] text-neutral-500 dark:text-fg-faint">{TYPE_LABELS[n.type] || n.type}</span>
<span class={statusPill}><StatusDot status={n.status} size="size-1.5" />{statusLabel(n.status)}</span>
</div>
{/snippet}
{#snippet openButton(n, cls, label)}
<a class="button {cls} w-full" href={n.link}>{label}</a>
{/snippet}
{#snippet removeButton(l)}
{#if canEdit}
<button type="button" class={smallButton(true)} title={l.removable ? removeTitle(l) : removeHint(l)} disabled={!l.removable || busy[l.id]} onclick={() => run(l.id, () => onremove(l))}>Remove</button>
{/if}
{/snippet}
{#if connectView && node && isSource(node)}
{#snippet body()}
<div class="flex flex-wrap items-center gap-2 px-4 pb-3">
{#if node.reference}<code class="rounded border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600 dark:border-white/[0.1] dark:bg-white/[0.05] dark:text-fg-dim">{node.reference}</code>{/if}
{#if node.missing}<span class="rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-medium text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400">Deleted</span>{/if}
</div>
{#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()}<p class="text-[12px] break-words">{node.subtitle}</p>{/snippet}
{@render section('Description', description)}
{/if}
{#snippet used()}
<ul class="flex flex-col gap-1.5">
{#if !usedBy.length}<li class={faint}>{text.noTargets}</li>{/if}
{#each usedBy as l (l.id)}
{@const resource = nodeById(l.target)}
<li class="flex items-center gap-1">
<button type="button" class={rowButton} title={resource?.context || ''} onclick={() => onselect({ kind: 'link', id: l.id })}>
<StatusDot status={resource?.status} size="size-1.5" />
<span class="min-w-0 flex-1 truncate">{resource?.name || 'Unknown'}</span>
{#if l.label}<span class="max-w-24 truncate font-mono text-[11px] text-neutral-400 dark:text-fg-faint">{l.label}</span>{/if}
</button>
{@render removeButton(l)}
</li>
{/each}
</ul>
{/snippet}
{@render section(`${text.usedBy} (${usedBy.length})`, used)}
{#if canEdit && !node.missing}
{@const linked = new Set(usedBy.map(l => l.target))}
<AddSection {...addProps('Add to resources', 'Search resources', text.allTargets,
graph.nodes.filter(n => !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()}<a class="text-[12px] hover:underline {titleLink}" href={node.context_link || undefined} draggable="false">{node.context}</a>{/snippet}
{@render section('Project / environment', context)}
{/if}
{#snippet list()}
<ul class="flex flex-col gap-1.5">
{#if !sources.length}<li class={faint}>{text.noSources}</li>{/if}
{#each sources as l (l.id)}
{@const source = nodeById(l.source)}
<li class="flex items-center gap-1">
<button type="button" class={rowButton} onclick={() => onselect({ kind: 'node', id: l.source })}>
<Icon name={source?.type || 'variable'} class="size-3.5 {l.missing ? 'text-red-500' : 'text-neutral-400 dark:text-fg-faint'}" />
<span class="min-w-0 flex-1 truncate {source?.type === 'variable' ? 'font-mono' : ''}">{source?.name || 'Unknown'}</span>
{#if l.missing}<span class="text-[11px] text-red-500">Deleted</span>{/if}
</button>
{@render removeButton(l)}
</li>
{/each}
</ul>
{/snippet}
{@render section(`${text.sources} (${sources.length})`, list)}
{#if canEdit}
{@const linked = new Set(sources.map(l => l.source))}
<AddSection {...addProps(text.addSources, text.searchSources, text.noMoreSources,
graph.nodes.filter(n => 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()}
<div class="flex flex-col gap-1">
{#each [[text.source, variable], ['Resource', resource]] as [endLabel, end] (endLabel)}
<button type="button" class={rowButton} onclick={() => end && onselect({ kind: 'node', id: end.id })}>
<span class="w-14 text-[11px] text-neutral-400 dark:text-fg-faint">{endLabel}</span>
<span class="min-w-0 flex-1 truncate {end?.type === 'variable' ? 'font-mono' : ''}">{end?.name || 'Unknown'}</span>
</button>
{/each}
</div>
{/snippet}
{@render section('Ends', ends)}
{#if link.keys?.length}
{#snippet keys()}
<div class="flex flex-wrap gap-1">
{#each link.keys as key (key)}<code class="rounded border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 font-mono text-[11px] dark:border-white/[0.1] dark:bg-white/[0.05]">{key}</code>{/each}
</div>
{/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()}
<button type="button" class="button w-full text-red-600! dark:text-red-400!" title={link.removable ? removeTitle(link) : removeHint(link)} disabled={!link.removable || busy[link.id]} onclick={() => run(link.id, () => onremove(link))}>Remove</button>
{/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()}<p class="text-[12px] break-words">{node.subtitle}</p>{/snippet}
{@render section('Details', details)}
{/if}
{#if node.urls?.length}
{#snippet urls()}
<ul class="flex flex-col gap-1">
{#each node.urls as u, i (i)}
<li class="min-w-0">
<a class="inline-flex max-w-full items-center gap-1.5 text-[12px] text-neutral-600 hover:text-black hover:underline dark:text-fg-dim dark:hover:text-fg" href={u} target="_blank" rel="noopener noreferrer">
<span class="truncate">{u.replace(/^https?:\/\//, '')}</span>
<Icon name="external" class="size-3" />
</a>
</li>
{/each}
</ul>
{/snippet}
{@render section(node.urls.length === 1 ? 'URL' : 'URLs', urls)}
{/if}
{#snippet serverList()}<p class="text-[12px]">{servers.length ? servers.join(', ') : NO_SERVER}</p>{/snippet}
{@render section(servers.length > 1 ? 'Servers' : 'Server', serverList)}
{#if node.children?.length}
{#snippet containers()}
<ul class="flex flex-col">
{#each node.children as child, i (i)}
<li class="flex h-6 items-center gap-2 text-[12px]" title={statusLabel(child.status)}>
<StatusDot status={child.status} size="size-1.5" />
<span class="min-w-0 flex-1 truncate">{child.name}</span>
<span class="text-[11px] text-neutral-400 dark:text-fg-faint">{TYPE_LABELS[child.type] || child.type || ''}</span>
</li>
{/each}
</ul>
{/snippet}
{@render section('Containers', containers)}
{/if}
{#if node.volumes?.length}
{#snippet volumes()}
<div class="flex flex-wrap gap-1">
{#each node.volumes as v, i (i)}<span class="max-w-full truncate rounded-full border border-neutral-200 bg-neutral-50 px-2 py-0.5 text-[11px] text-neutral-600 dark:border-white/[0.1] dark:bg-white/[0.05] dark:text-fg-dim">{v}</span>{/each}
</div>
{/snippet}
{@render section('Volumes', volumes)}
{/if}
{#snippet linkList()}
<ul class="flex flex-col gap-0.5">
{#if !nodeLinks.length}<li class={faint}>{canEdit ? 'No links. Drag the dot on the right edge of a card to another card.' : 'No links.'}</li>{/if}
{#each nodeLinks as l (l.id)}
<li>
<button type="button" class={listButton} onclick={() => onselect({ kind: 'link', id: l.id })}>
<Icon name="link" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span class="min-w-0 flex-1 truncate">{nodeById(l.source === node.id ? l.target : l.source)?.name || 'Unknown'}</span>
{#if l.label}<span class="max-w-24 truncate text-[11px] text-neutral-400 dark:text-fg-faint">{l.label}</span>{/if}
</button>
</li>
{/each}
</ul>
{/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()}
<div class="flex flex-col gap-1">
{#each [['From', link.source], ['To', link.target]] as [endLabel, id] (endLabel)}
{@const end = nodeById(id)}
<button type="button" class={listButton} onclick={() => onselect({ kind: 'node', id })}>
<span class="w-9 text-[11px] text-neutral-400 dark:text-fg-faint">{endLabel}</span>
<StatusDot status={end?.status} size="size-1.5" />
<span class="min-w-0 flex-1 truncate">{end?.name || 'Unknown'}</span>
</button>
{/each}
</div>
{/snippet}
{@render section('Resources', ends)}
{#snippet labelField()}
{#if canEdit}
<input
bind:this={labelInput}
bind:value={label}
class="input h-8 w-full text-[12px]"
type="text"
maxlength="255"
placeholder="For example: reads from"
aria-label="Link label"
data-ec-label-input
onkeydown={e => {
if (e.key === 'Enter') {
e.preventDefault();
labelInput.blur();
} else if (e.key === 'Escape') {
e.stopPropagation();
label = saved;
labelInput.blur();
}
}}
onblur={saveLabel}
/>
{:else}
<p class="text-[12px]">{link.label || 'No label'}</p>
{/if}
{/snippet}
{@render section('Label', labelField)}
{@render note('Links are for the diagram only. They do not change your resources.')}
{/snippet}
{#snippet footer()}
<button type="button" class="button w-full text-red-600! dark:text-red-400!" disabled={busy[link.id]} onclick={() => run(link.id, () => ondelete(link))}>Remove link</button>
{/snippet}
{@render shell(link.label || 'Link', 'link', body, canEdit ? footer : null)}
{/if}
@@ -0,0 +1,13 @@
<script>
import { statusLabel, statusTone, TONE_DOT } from '../../environment-canvas.js';
let { status, size = 'size-2' } = $props();
const tone = $derived(statusTone(status));
</script>
<span class="relative flex {size} shrink-0" title={statusLabel(status)}>
{#if tone === 'busy'}
<span class="absolute inline-flex size-full rounded-full opacity-75 motion-safe:animate-ping {TONE_DOT.busy}"></span>
{/if}
<span class="relative inline-flex size-full rounded-full {TONE_DOT[tone]}"></span>
</span>
@@ -0,0 +1,51 @@
<script>
// Title, filters, refresh and zoom. The root docks it (use:dock) into the app top bar on desktop,
// or in a rounded box above the canvas on small screens.
import Icon from './Icon.svelte';
let { title, viewOnly, viewOnlyTitle, filter, projects, environments, loading, zoom, views, dock, onfilter, onrefresh, onzoom, onfit } = $props();
const select = 'select h-8! w-44 py-0! text-[12px]! max-lg:w-auto max-lg:min-w-0 max-lg:flex-1 max-lg:basis-32';
const value = (options, wanted) => (options.some(o => o.uuid === wanted) ? wanted : '');
/** Table / grid / canvas switcher from the page (Blade), when the page has one. */
function adopt(node, el) {
if (!el) return;
el.classList.add('max-lg:flex-wrap');
node.append(el);
}
</script>
<div use:dock data-ec-toolbar>
<div class="flex min-w-0 items-center gap-2">
{#if title}<b class="shrink-0 text-[14px] font-semibold text-black dark:text-fg">{title}</b>{/if}
<span class="inline-flex items-center">
<span class="px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-coollabs/10 text-coollabs border border-coollabs/30 dark:bg-warning/15 dark:text-warning dark:border-warning/30">Beta</span>
</span>
<span class="rounded-full border border-neutral-200 bg-neutral-100 px-2 py-0.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.12] dark:bg-white/[0.07] dark:text-fg-dim" class:hidden={!viewOnly} title={viewOnlyTitle}>View only</span>
</div>
<!-- Small screens: the actions wrap onto more rows instead of covering the title. -->
<div class="ml-auto flex shrink-0 items-center gap-2 max-lg:min-w-0 max-lg:shrink max-lg:flex-wrap" use:adopt={views}>
<!-- Connect and server views: show the resources of one project and / or environment only. -->
<select class={select} class:hidden={projects.length < 2} aria-label="Filter by project" title="Filter by project"
value={value(projects, filter.project)} onchange={e => onfilter({ project: e.currentTarget.value, environment: '' })}>
<option value="">All projects</option>
{#each projects as o (o.uuid)}<option value={o.uuid}>{o.name}</option>{/each}
</select>
<select class={select} class:hidden={environments.length < 2} aria-label="Filter by environment" title="Filter by environment"
value={value(environments, filter.environment)} onchange={e => onfilter({ ...filter, environment: e.currentTarget.value })}>
<option value="">All environments</option>
{#each environments as o (o.uuid)}<option value={o.uuid}>{o.name}</option>{/each}
</select>
<button type="button" class="button" title="Load the latest status" disabled={loading} onclick={onrefresh}>
<Icon name="refresh" class="size-3.5 {loading ? 'animate-spin' : ''}" />
<span class="hidden sm:inline">Refresh</span>
</button>
<div class="flex items-center">
<button type="button" class="button px-2! rounded-r-none!" title="Zoom out" aria-label="Zoom out" onclick={() => onzoom(zoom / 1.2)}><Icon name="minus" /></button>
<button type="button" class="button rounded-none! border-x-0! w-14 tabular-nums" title="Reset zoom to 100%" onclick={() => onzoom(1)}>{Math.round(zoom * 100)}%</button>
<button type="button" class="button px-2! rounded-l-none!" title="Zoom in" aria-label="Zoom in" onclick={() => onzoom(zoom * 1.2)}><Icon name="plus" /></button>
</div>
<button type="button" class="button px-2!" title="Fit to screen" aria-label="Fit to screen" onclick={onfit}><Icon name="fit" />Fit</button>
</div>
</div>
@@ -0,0 +1,32 @@
<script>
// A zone frame (server, or a group of the connect view) with its header; the header drags the zone.
import { NO_SERVER } from '../../environment-canvas.js';
import Icon from './Icon.svelte';
let { box, movable, onpress } = $props();
let header;
const zone = $derived(box.zone);
const ok = $derived(zone?.is_reachable && zone?.is_usable);
const title = $derived(movable ? 'Drag to move every card in this zone' : !zone ? 'Resources without a server' : undefined);
</script>
<div
class="absolute rounded-2xl border border-neutral-200 bg-white/50 dark:border-white/[0.07] dark:bg-white/[0.02]"
style:left="{box.x}px"
style:top="{box.y}px"
style:width="{box.width}px"
style:height="{box.height}px"
>
<div bind:this={header} class="flex h-10 items-center gap-2 px-4 text-[12px] {movable ? 'cursor-grab active:cursor-grabbing' : ''}" data-ec-zone-header {title} role="presentation" onpointerdown={e => onpress(e, header)}>
<Icon name={zone?.icon || 'server'} class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<a class="truncate font-semibold text-neutral-700 dark:text-fg [&[href]]:cursor-pointer [&[href]]:underline-offset-2 [&[href]]:hover:underline" href={zone?.link || undefined} draggable="false">{box.name || NO_SERVER}</a>
<!-- Zones with an icon are not server zones (connect view). -->
{#if zone && !zone.icon}
<span
class="size-1.5 shrink-0 rounded-full {ok ? 'bg-emerald-500' : zone.is_reachable ? 'bg-warning' : 'bg-red-500'}"
title={ok ? 'Reachable' : zone.is_reachable ? 'Reachable, not usable' : 'Not reachable'}
></span>
{#if zone.ip}<span class="truncate font-mono text-[11px] text-neutral-400 dark:text-fg-faint">{zone.ip}</span>{/if}
{/if}
</div>
</div>
+11
View File
@@ -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) };
});
}
+510
View File
@@ -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}`;
}
+178
View File
@@ -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);
});
+621
View File
@@ -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';
+437
View File
@@ -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');
});
@@ -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' => [...]]". --}}
<div {{ $attributes->merge(['class' => 'view-toggle']) }}>
@foreach ($views as $mode => [$label, $icon])
@if ($onCanvas)
<a href="{{ $listUrl }}?view={{ $mode }}" {{ wireNavigate() }} class="{{ $item }} {{ $idle }}"
aria-label="{{ $label }}" title="{{ $label }}">
<x-reicon :name="$icon" class="size-3.5" />
</a>
@elseif ($singleList)
<span class="{{ $item }} control-selected" aria-current="page" aria-label="{{ $label }}"
title="{{ $label }}">
<x-reicon :name="$icon" class="size-3.5" />
</span>
@else
<button type="button" x-on:click="setViewMode(@js($mode))" class="{{ $item }}"
:class="viewMode === @js($mode) ? 'control-selected' : @js($idle)"
aria-label="{{ $label }}" title="{{ $label }}">
<x-reicon :name="$icon" class="size-3.5" />
</button>
@endif
@endforeach
@if ($onCanvas)
<span class="{{ $canvasItem }} control-selected" aria-current="page" title="Canvas view (beta)">
<x-reicon name="deploy-canvas" class="size-3.5" />
Canvas
</span>
@else
{{-- A label and a purple "Try it" badge, so people notice the canvas view. --}}
<a href="{{ $canvasUrl }}" {{ wireNavigate() }}
class="{{ $canvasItem }} {{ $idle }}" title="Canvas view (beta): see everything and how it connects">
<x-reicon name="deploy-canvas" class="size-3.5" />
Canvas
<span
class="ml-0.5 inline-flex items-center rounded-full bg-coollabs px-1.5 py-px text-[10px] leading-4 font-semibold text-white">
Try it
</span>
</a>
@endif
</div>
@@ -109,7 +109,7 @@
@if ($showNav)
<nav class="mb-6 w-full lg:mb-0">
<div class="w-full lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
<div class="app-tabbar w-full lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
{{-- Mobile: stack tabs then actions so long buttons never crush the tab strip.
Desktop: single fixed row with tabs left and actions right. --}}
+1 -1
View File
@@ -62,7 +62,7 @@
:title="hasPageSearch
? 'Search this page and everything (Press / or ' + modKeyLabel + 'K, then Tab to switch)'
: 'Search (Press / or ' + modKeyLabel + 'K)'"
class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint">
class="menu-item menu-search justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint">
<span class="flex items-center gap-2.5 min-w-0">
<x-reicon name="search" class="menu-item-icon" />
<span class="menu-item-label truncate" :class="collapsed && 'lg:hidden'">Search</span>
@@ -9,8 +9,10 @@
'fire' => '<path d="M12.83 2.18a.75.75 0 0 0-1.15.64c0 2.04-.88 3.72-2.31 5.19-.45-1.13-.59-2.31-.42-3.55a.75.75 0 0 0-1.17-.72C4.55 5.96 2.75 9.25 2.75 13a9.25 9.25 0 0 0 18.5 0c0-4.72-3.28-8.6-8.42-10.82ZM12 20.75A7.75 7.75 0 0 1 4.25 13c0-2.67 1.07-5.1 3.05-6.94.08 1.37.46 2.68 1.12 3.93a.75.75 0 0 0 1.18.19c1.85-1.62 3.03-3.48 3.43-5.6 4.13 2.09 6.72 5.13 6.72 8.42A7.75 7.75 0 0 1 12 20.75Z" fill="currentColor"/><path d="M12.38 10.13a.75.75 0 0 0-1.26.32c-.33 1.2-1.06 2.06-1.7 2.82-.69.81-1.17 1.52-1.17 2.73a3.75 3.75 0 0 0 7.5 0c0-2.13-1.2-4.1-3.37-5.87ZM12 18.25A2.25 2.25 0 0 1 9.75 16c0-.65.24-1.03.82-1.72.48-.57 1.03-1.21 1.45-2.06 1.47 1.37 2.23 2.63 2.23 3.78A2.25 2.25 0 0 1 12 18.25Z" fill="currentColor"/>',
'cloud' => '<path d="M7.5 19.25h9.75a4.5 4.5 0 0 0 .62-8.96A6.25 6.25 0 0 0 5.8 8.65 5.25 5.25 0 0 0 7.5 19.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
'brain' => '<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18V5"/><path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4"/><path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5"/><path d="M17.997 5.125a4 4 0 0 1 2.526 5.77"/><path d="M18 18a4 4 0 0 0 2-7.464"/><path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517"/><path d="M6 18a4 4 0 0 1-2-7.464"/><path d="M6.003 5.125a4 4 0 0 0-2.526 5.77"/></g>',
'link' => '<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
'code' => '<path d="m8.5 7-5 5 5 5M15.5 7l5 5-5 5M13.5 4l-3 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
'analytics' => '<path d="M5 3v14a2 2 0 0 0 2 2h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 17v-3.5M13 17v-7M17 17v-10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
'deploy-canvas' => '<rect x="3.75" y="3.75" width="16.5" height="16.5" rx="3" stroke="currentColor" stroke-width="1.5"/><g fill="currentColor"><circle cx="8" cy="8" r="1"/><circle cx="12" cy="8" r="1"/><circle cx="16" cy="8" r="1"/><circle cx="8" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="16" cy="12" r="1"/><circle cx="8" cy="16" r="1"/><circle cx="12" cy="16" r="1"/><circle cx="16" cy="16" r="1"/></g>',
'mail' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 3.25h13A4.25 4.25 0 0 1 22.75 7.5v9a4.25 4.25 0 0 1-4.25 4.25h-13A4.25 4.25 0 0 1 1.25 16.5v-9A4.25 4.25 0 0 1 5.5 3.25Zm-2.75 4.5v8.75a2.75 2.75 0 0 0 2.75 2.75h13a2.75 2.75 0 0 0 2.75-2.75V7.75l-6.6 5.13a4.25 4.25 0 0 1-5.3 0l-6.6-5.13Zm18.4-1.3A2.75 2.75 0 0 0 18.5 4.75h-13a2.75 2.75 0 0 0-2.65 1.7l7.42 5.77a2.75 2.75 0 0 0 3.46 0l7.42-5.77Z" fill="currentColor"/>',
'dashboard' => '<path d="M9 17.25C8.58579 17.25 8.25 17.5858 8.25 18C8.25 18.4142 8.58579 18.75 9 18.75H15C15.4142 18.75 15.75 18.4142 15.75 18C15.75 17.5858 15.4142 17.25 15 17.25H9Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.25C11.2919 1.25 10.6485 1.45282 9.95055 1.79224C9.27585 2.12035 8.49642 2.60409 7.52286 3.20832L5.45628 4.4909C4.53509 5.06261 3.79744 5.5204 3.2289 5.95581C2.64015 6.40669 2.18795 6.86589 1.86131 7.46263C1.53535 8.05812 1.38857 8.69174 1.31819 9.4407C1.24999 10.1665 1.24999 11.0541 1.25 12.1672V13.7799C1.24999 15.6837 1.24998 17.1866 1.4027 18.3616C1.55937 19.567 1.88856 20.5401 2.63236 21.3094C3.37958 22.0824 4.33046 22.4277 5.50761 22.5914C6.64849 22.75 8.10556 22.75 9.94185 22.75H14.0581C15.8944 22.75 17.3515 22.75 18.4924 22.5914C19.6695 22.4277 20.6204 22.0824 21.3676 21.3094C22.1114 20.5401 22.4406 19.567 22.5973 18.3616C22.75 17.1866 22.75 15.6838 22.75 13.7799V12.1672C22.75 11.0541 22.75 10.1665 22.6818 9.4407C22.6114 8.69174 22.4646 8.05812 22.1387 7.46263C21.8121 6.86589 21.3599 6.40669 20.7711 5.95581C20.2026 5.5204 19.4649 5.06262 18.5437 4.49091L16.4771 3.20831C15.5036 2.60409 14.7241 2.12034 14.0494 1.79224C13.3515 1.45282 12.7081 1.25 12 1.25ZM8.27953 4.50412C9.29529 3.87371 10.0095 3.43153 10.6065 3.1412C11.1882 2.85833 11.6002 2.75 12 2.75C12.3998 2.75 12.8118 2.85833 13.3935 3.14119C13.9905 3.43153 14.7047 3.87371 15.7205 4.50412L17.7205 5.74537C18.6813 6.34169 19.3559 6.76135 19.8591 7.1467C20.3487 7.52164 20.6303 7.83106 20.8229 8.18285C21.0162 8.53589 21.129 8.94865 21.1884 9.58104C21.2492 10.2286 21.25 11.0458 21.25 12.2039V13.725C21.25 15.6959 21.2485 17.1012 21.1098 18.1683C20.9736 19.2163 20.717 19.8244 20.2892 20.2669C19.8649 20.7058 19.2871 20.9664 18.2858 21.1057C17.2602 21.2483 15.9075 21.25 14 21.25H10C8.09247 21.25 6.73983 21.2483 5.71422 21.1057C4.71286 20.9664 4.13514 20.7058 3.71079 20.2669C3.28301 19.8244 3.02642 19.2163 2.89019 18.1683C2.75149 17.1012 2.75 15.6959 2.75 13.725V12.2039C2.75 11.0458 2.75076 10.2286 2.81161 9.58104C2.87103 8.94865 2.98385 8.53589 3.17709 8.18285C3.36965 7.83106 3.65133 7.52164 4.14092 7.1467C4.6441 6.76135 5.31869 6.34169 6.27953 5.74537L8.27953 4.50412Z" fill="currentColor"/>',
'projects' => '<path d="M5 10H7C9 10 10 9 10 7V5C10 3 9 2 7 2H5C3 2 2 3 2 5V7C2 9 3 10 5 10Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 10H19C21 10 22 9 22 7V5C22 3 21 2 19 2H17C15 2 14 3 14 5V7C14 9 15 10 17 10Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 22H19C21 22 22 21 22 19V17C22 15 21 14 19 14H17C15 14 14 15 14 17V19C14 21 15 22 17 22Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 22H7C9 22 10 21 10 19V17C10 15 9 14 7 14H5C3 14 2 15 2 17V19C2 21 3 22 5 22Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>',
@@ -0,0 +1,31 @@
@props(['resource', 'iconClass' => 'size-4'])
{{-- The resource logo (see resource_logo()). Shows the favicon once it loads, and the resource type icon when there is no logo or it fails to load. --}}
@php
$logo = resource_logo($resource);
$icon = match (true) {
$resource instanceof \App\Models\Application => 'browser-code',
$resource instanceof \App\Models\Service => 'layers',
default => 'database',
};
@endphp
<span class="contents" data-resource-logo>
@if ($logo)
<span class="flex size-full items-center justify-center" data-resource-logo-image>
<img src="{{ $logo['src'] }}" alt="" data-fallback="{{ $logo['fallback'] }}"
onerror="resourceLogoError(this)"
@class(['size-full object-contain', 'p-1' => $logo['padded'], 'dark:hidden' => $logo['dark']]) />
@if ($logo['dark'])
<img src="{{ $logo['dark'] }}" alt="" onerror="resourceLogoError(this)"
class="hidden size-full object-contain dark:block" />
@endif
</span>
@if ($logo['favicon'])
<img src="{{ $logo['favicon'] }}" alt="" hidden data-resource-logo-favicon
onload="resourceLogoLoaded(this)" onerror="resourceLogoError(this)"
class="size-full object-contain p-1" />
@endif
@endif
<x-reicon :name="$icon" :class="$iconClass" data-resource-logo-icon :hidden="(bool) $logo" />
</span>
@@ -186,10 +186,11 @@
const panel = qs(root, '[data-sth-panel]');
if (panel) {
// Dropdown below the pill inside the top bar.
// Dropdown below the pill inside the top bar. The desktop pill sits after the
// breadcrumb (left side), so its panel opens to the right; the mobile pill is on the right.
panel.style.position = 'absolute';
panel.style.right = '0';
panel.style.left = 'auto';
panel.style.right = isMobileSlot ? '0' : 'auto';
panel.style.left = isMobileSlot ? 'auto' : '0';
panel.style.top = 'calc(100% + 8px)';
panel.style.bottom = 'auto';
panel.style.zIndex = '2147483001';
@@ -26,7 +26,7 @@
'active' => $activeMenu === 'advanced',
'icon' => 'grid',
'group' => 'Settings',
'visible' => $server->isFunctional(),
'disabled' => ! $server->isFunctional(),
],
[
'label' => 'Private Key',
@@ -80,7 +80,8 @@
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
'icon' => 'shield-star',
'group' => 'Platform',
'visible' => $server->isFunctional() && ! $server->isSwarm() && $server->canHostResources() && auth()->user()?->can('viewSentinel', $server),
'visible' => ! $server->isSwarm() && $server->canHostResources() && auth()->user()?->can('viewSentinel', $server),
'disabled' => ! $server->isFunctional(),
'warning' => $server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync',
'tracks_sentinel_status' => true,
'children' => [
@@ -110,7 +111,7 @@
'active' => $activeMenu === 'destinations',
'icon' => 'destinations',
'group' => 'Networking',
'visible' => $server->isFunctional(),
'disabled' => ! $server->isFunctional(),
],
[
'label' => 'Swarm',
@@ -126,7 +127,7 @@
'active' => $activeMenu === 'docker-images',
'icon' => 'layers',
'group' => 'Operations',
'visible' => $server->isFunctional(),
'disabled' => ! $server->isFunctional(),
],
[
'label' => 'Docker Cleanup',
@@ -134,7 +135,7 @@
'active' => $activeMenu === 'docker-cleanup',
'icon' => 'broom',
'group' => 'Operations',
'visible' => $server->isFunctional(),
'disabled' => ! $server->isFunctional(),
],
[
'label' => 'GitHub Runners',
@@ -159,7 +160,7 @@
'active' => $activeMenu === 'log-drains',
'icon' => 'notifications',
'group' => 'Operations',
'visible' => $server->isFunctional(),
'disabled' => ! $server->isFunctional(),
],
[
'label' => 'Metrics',
@@ -167,7 +168,7 @@
'active' => $activeMenu === 'metrics',
'icon' => 'graph',
'group' => 'Operations',
'visible' => $server->isFunctional(),
'disabled' => ! $server->isFunctional(),
],
[
'label' => 'Analytics',
@@ -175,7 +176,8 @@
'active' => $activeMenu === 'analytics',
'icon' => 'analytics',
'group' => 'Operations',
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->isBuildServer(),
'visible' => ! $server->isSwarm() && ! $server->isBuildServer(),
'disabled' => ! $server->isFunctional(),
],
[
'label' => 'Security',
@@ -266,6 +268,15 @@
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
@if ($menuItem['disabled'] ?? false)
<span wire:key="server-settings-link-{{ str($menuItem['label'])->slug() }}"
class="menu-item cursor-not-allowed opacity-40" aria-disabled="true"
title="Validate the server connection first.">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</span>
@continue
@endif
<a wire:key="server-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
@@ -17,6 +17,12 @@
'scope' => mb_strtolower(str($type)->headline()),
])
->values();
$canvasUrl = match ($type) {
'team' => route('shared-variables.team.canvas'),
'project' => route('shared-variables.project.canvas', ['project_uuid' => $resource->uuid]),
'environment' => route('shared-variables.environment.canvas', ['project_uuid' => $resource->project->uuid, 'environment_uuid' => $resource->uuid]),
'server' => route('shared-variables.server.canvas', ['server_uuid' => $resource->uuid]),
};
@endphp
<x-shared-variables.layout>
@@ -38,6 +44,8 @@
<span class="max-sm:hidden">{{ $view === 'normal' ? 'Developer view' : 'Normal view' }}</span>
<span class="sm:hidden">{{ $view === 'normal' ? 'Developer' : 'Normal' }}</span>
</x-forms.button>
<x-canvas-view-toggle :views="['list' => ['List view', 'unordered-list']]" :list-url="url()->current()"
:canvas-url="$canvasUrl" />
</x-slot:actions>
@if ($view === 'normal')
<div
@@ -1,13 +1,3 @@
@php
$sharedVariablesMenuItems = [
['label' => 'Overview', 'route' => 'shared-variables.index', 'icon' => 'dashboard', 'active' => request()->routeIs('shared-variables.index')],
['label' => 'Team', 'route' => 'shared-variables.team.index', 'icon' => 'teams', 'active' => request()->routeIs('shared-variables.team.*')],
['label' => 'Projects', 'route' => 'shared-variables.project.index', 'icon' => 'projects', 'active' => request()->routeIs('shared-variables.project.*')],
['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'icon' => 'layers', 'active' => request()->routeIs('shared-variables.environment.*')],
['label' => 'Servers', 'route' => 'shared-variables.server.index', 'icon' => 'servers', 'active' => request()->routeIs('shared-variables.server.*')],
];
@endphp
<section class="application-settings-workspace w-full max-w-none">
<header class="mb-6 xl:hidden">
<h1 class="text-[24px]! leading-7! font-semibold! tracking-tight!">Shared variables</h1>
@@ -15,20 +5,7 @@
</header>
<div class="grid min-w-0 gap-8 xl:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Shared variables' => $sharedVariablesMenuItems], [])) }}">
<nav aria-label="Shared variables"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($sharedVariablesMenuItems as $menuItem)
<a wire:key="shared-variables-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
<x-shared-variables.sidebar />
<div class="min-w-0 xl:mt-3">
{{ $slot }}
@@ -0,0 +1,24 @@
@php
$sharedVariablesMenuItems = [
['label' => 'Overview', 'route' => 'shared-variables.index', 'icon' => 'dashboard', 'active' => request()->routeIs('shared-variables.index')],
['label' => 'Team', 'route' => 'shared-variables.team.index', 'icon' => 'teams', 'active' => request()->routeIs('shared-variables.team.*')],
['label' => 'Projects', 'route' => 'shared-variables.project.index', 'icon' => 'projects', 'active' => request()->routeIs('shared-variables.project.*')],
['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'icon' => 'layers', 'active' => request()->routeIs('shared-variables.environment.*')],
['label' => 'Servers', 'route' => 'shared-variables.server.index', 'icon' => 'servers', 'active' => request()->routeIs('shared-variables.server.*')],
];
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Shared variables' => $sharedVariablesMenuItems], [])) }}">
<nav aria-label="Shared variables"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($sharedVariablesMenuItems as $menuItem)
<a wire:key="shared-variables-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
@@ -0,0 +1,10 @@
@props(['team'])
@if ($team->avatar_path)
<img src="{{ team_avatar_url($team) }}" alt="{{ $team->name }}"
{{ $attributes->class('shrink-0 rounded-md object-cover') }}>
@else
<span {{ $attributes->class('flex shrink-0 items-center justify-center rounded-md bg-neutral-200 font-semibold text-neutral-700 dark:bg-white/[0.1] dark:text-fg') }}>
{{ strtoupper(mb_substr($team->name, 0, 1)) }}
</span>
@endif
@@ -61,6 +61,44 @@
</svg>
</button>
@endforeach
<div class="my-1 h-px bg-neutral-200 dark:bg-white/[0.07]"></div>
<div class="px-2 pt-1 pb-0.5 text-[10px] font-medium tracking-wide text-neutral-400 uppercase dark:text-fg-faint">
Navigation
</div>
@foreach ([
['value' => 'default', 'label' => 'Default'],
['value' => 'floating', 'label' => 'Floating'],
] as $option)
<button type="button" @click="setNavStyle('{{ $option['value'] }}')"
class="flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span>{{ $option['label'] }}</span>
<svg x-show="navStyle === '{{ $option['value'] }}'"
class="size-3.5 text-black dark:text-fg" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="{{ $checkPath }}" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endforeach
<div class="my-1 h-px bg-neutral-200 dark:bg-white/[0.07]"></div>
<div class="px-2 pt-1 pb-0.5 text-[10px] font-medium tracking-wide text-neutral-400 uppercase dark:text-fg-faint">
Navigation items
</div>
@foreach ([
['value' => '3d', 'label' => '3D'],
['value' => 'flat', 'label' => 'Flat'],
] as $option)
<button type="button" @click="setNavItems('{{ $option['value'] }}')"
class="flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span>{{ $option['label'] }}</span>
<svg x-show="navItems === '{{ $option['value'] }}'"
class="size-3.5 text-black dark:text-fg" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="{{ $checkPath }}" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endforeach
</div>
@else
{{-- Full card grid for the Appearance settings page. --}}
@@ -181,5 +219,105 @@
@endforeach
</div>
</section>
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Navigation style</h2>
<p>Choose how the top bar and sidebars sit on the page.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-3 sm:grid-cols-2">
@foreach ([
['value' => 'default', 'label' => 'Default', 'description' => 'Top bar and sidebars run edge to edge.'],
['value' => 'floating', 'label' => 'Floating', 'description' => 'Framed panels float on a dotted backdrop, like the canvas.'],
] as $option)
<button type="button" @click="setNavStyle('{{ $option['value'] }}')"
class="group overflow-hidden rounded-[10px] border border-neutral-200 bg-white text-left transition-[border-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-white/[0.05] dark:hover:border-white/[0.12]"
:class="navStyle === '{{ $option['value'] }}'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
@php($floating = $option['value'] === 'floating')
<div @class([
'flex h-20 border-b border-neutral-200 bg-neutral-50 dark:border-white/[0.07] dark:bg-black/15',
'gap-1.5 p-1.5' => $floating,
])>
<div @class([
'flex flex-col items-center gap-1.5 bg-white px-2 py-2 dark:bg-white/[0.04]',
'rounded-lg border border-neutral-200 dark:border-white/[0.08]' => $floating,
'border-r border-neutral-200 dark:border-white/[0.07]' => ! $floating,
])>
@foreach (['servers', 'projects'] as $icon)
<span class="flex size-6 items-center justify-center rounded-md border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon :name="$icon" class="size-3.5" />
</span>
@endforeach
</div>
<div @class([
'h-5 flex-1 bg-white dark:bg-white/[0.04]',
'rounded-lg border border-neutral-200 dark:border-white/[0.08]' => $floating,
'border-b border-neutral-200 dark:border-white/[0.07]' => ! $floating,
])></div>
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">{{ $option['label'] }}</span>
<x-reicon name="check-circle" class="size-4 text-black dark:text-fg"
x-show="navStyle === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">{{ $option['description'] }}</p>
</div>
</button>
@endforeach
</div>
</section>
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Navigation items</h2>
<p>Choose how the main sidebar shows its items.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-3 sm:grid-cols-2">
@foreach ([
['value' => '3d', 'label' => '3D', 'description' => 'Each icon sits in a raised tile that lifts on hover.'],
['value' => 'flat', 'label' => 'Flat', 'description' => 'Plain rows with a highlight for the current page.'],
] as $option)
<button type="button" @click="setNavItems('{{ $option['value'] }}')"
class="group overflow-hidden rounded-[10px] border border-neutral-200 bg-white text-left transition-[border-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-white/[0.05] dark:hover:border-white/[0.12]"
:class="navItems === '{{ $option['value'] }}'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
@php($tiles = $option['value'] === '3d')
<div class="flex h-20 flex-col justify-center gap-1.5 border-b border-neutral-200 bg-neutral-50 px-4 dark:border-white/[0.07] dark:bg-black/15">
@foreach (['servers', 'projects'] as $icon)
<div @class([
'flex items-center gap-2 rounded-md text-neutral-500 dark:text-fg-dim',
'px-1.5 py-1' => ! $tiles,
'bg-neutral-200/70 dark:bg-white/[0.08]' => ! $tiles && $loop->first,
])>
<span @class([
'flex items-center justify-center',
'size-6 rounded-md border border-neutral-200 bg-white shadow-[0_2px_0_rgb(0_0_0/0.22)] dark:border-white/[0.09] dark:bg-white/[0.04] dark:shadow-[0_2px_0_rgb(255_255_255/0.08)]' => $tiles,
])>
<x-reicon :name="$icon" class="size-3.5" />
</span>
<span class="h-1.5 w-16 rounded-full bg-neutral-200 dark:bg-white/10"></span>
</div>
@endforeach
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">{{ $option['label'] }}</span>
<x-reicon name="check-circle" class="size-4 text-black dark:text-fg"
x-show="navItems === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">{{ $option['description'] }}</p>
</div>
</button>
@endforeach
</div>
</section>
</div>
@endif
@@ -85,7 +85,7 @@
$currentTag = $tags->firstWhere('name', $tagName);
$dashboardContext = match (true) {
request()->routeIs('dashboard') => 'Dashboard',
request()->routeIs('project.index') => 'Projects',
request()->routeIs('project.index', 'project.canvas.index') => 'Projects',
request()->routeIs('terminal') => 'Terminal',
request()->routeIs('server.*') => 'Servers',
request()->routeIs('source.*') => 'Sources',
+32 -15
View File
@@ -14,7 +14,7 @@
autoCollapse: localStorage.getItem('sidebarAutoCollapse') !== 'false',
hasSecondBar: false,
collapsed: false,
pageWidth: localStorage.getItem('pageWidth') || 'full',
pageWidth: localStorage.getItem('pageWidth') || 'centered',
sidebarReady: false,
init() {
this.applyCollapsed(false);
@@ -26,7 +26,9 @@
},
targetCollapsed() {
this.hasSecondBar = !!document.querySelector('.application-settings-navigation');
return this.hasSidebarPreference ? this.userCollapsed : (this.autoCollapse && this.hasSecondBar);
// New users start collapsed; auto-collapse still applies on pages with a settings menu.
const preferred = this.hasSidebarPreference ? this.userCollapsed : true;
return preferred || (this.autoCollapse && this.hasSecondBar);
},
applyCollapsed(animate) {
const target = this.targetCollapsed();
@@ -53,13 +55,16 @@
}
}" @open-global-search.window="open = false" @page-width-changed.window="pageWidth = $event.detail" x-on:livewire:navigated.window="applyCollapsed(true)"
:style="{ '--sidebar-w': collapsed ? '4rem' : '14rem' }" x-cloak
class="dark:text-inherit text-black">
@class(['app-shell dark:text-inherit text-black', 'flex h-dvh flex-col' => $fullBleed ?? false])>
{{-- ============ DESKTOP TOP BAR ============ --}}
<header
x-data="{ resourceActionsOpen: false }"
{{-- Floating style: the resource actions are a separate panel at the top right.
Publish its width so the top bar panel stops before it. --}}
x-init="new ResizeObserver(([entry]) => $el.style.setProperty('--resource-actions-w', entry.borderBoxSize[0].inlineSize + 'px')).observe($refs.resourceActions)"
@resource-actions-toggled.window="resourceActionsOpen = $event.detail.open"
:class="{ 'z-[1000]': resourceActionsOpen }"
class="hidden lg:flex fixed top-0 inset-x-0 z-50 h-12 items-center bg-white/95 dark:bg-panel/95 backdrop-blur">
class="app-topbar hidden lg:flex fixed top-0 inset-x-0 z-50 h-12 items-center bg-white/95 dark:bg-panel/95 backdrop-blur">
{{-- Brand (width tracks sidebar) --}}
<div class="flex items-center gap-2 h-full shrink-0 border-r border-neutral-200 dark:border-white/[0.06] transition-[width] duration-200"
:class="collapsed ? 'w-16 justify-center px-0' : 'w-56 px-4'">
@@ -87,12 +92,14 @@
<div class="relative flex min-w-0 flex-1 items-center">
<x-top-breadcrumb />
<div id="server-topbar-context" class="min-w-0"></div>
{{-- Dev Server-Timing HUD docks here, after the breadcrumb (local only; empty in production) --}}
<div id="server-timing-hud-slot" data-server-timing-hud-slot class="ml-2 hidden shrink-0 items-center"></div>
</div>
{{-- Dev Server-Timing HUD docks here (local only; empty in production) --}}
<div id="server-timing-hud-slot" data-server-timing-hud-slot class="hidden shrink-0 items-center"></div>
{{-- Page toolbars (the canvases) dock here on desktop. --}}
<div id="page-toolbar-slot" class="flex shrink-0 items-center empty:hidden"></div>
<div id="configuration-warning-hud-slot" class="relative shrink-0"></div>
{{-- Resource actions dock here on desktop. --}}
<div id="resource-action-hud-slot" class="hidden shrink-0 items-center xl:flex"></div>
<div id="resource-action-hud-slot" x-ref="resourceActions" class="hidden shrink-0 items-center xl:flex"></div>
</div>
</header>
@@ -140,7 +147,7 @@
</div>
{{-- ============ DESKTOP SIDEBAR (below top bar) ============ --}}
<div class="hidden lg:fixed lg:top-12 lg:bottom-0 lg:left-0 lg:z-40 lg:flex lg:flex-col min-w-0"
<div class="app-sidebar hidden lg:fixed lg:top-12 lg:bottom-0 lg:left-0 lg:z-40 lg:flex lg:flex-col min-w-0"
:class="[collapsed ? 'lg:w-16' : 'lg:w-56', sidebarReady ? 'transition-[width] duration-200' : '']">
<div class="flex grow min-w-0 flex-col overflow-visible">
<x-navbar deployments-indicator />
@@ -181,13 +188,23 @@
</div>
{{-- ============ MAIN ============ --}}
<main
class="min-h-screen bg-neutral-50 dark:bg-app px-5 py-6 sm:px-8 lg:px-10 lg:pt-[calc(3rem+1.75rem)] lg:pb-10"
:class="[collapsed ? 'lg:ml-16' : 'lg:ml-56', sidebarReady ? 'transition-[margin] duration-200' : '']">
<div class="w-full" :class="pageWidth === 'centered' ? 'mx-auto max-w-[1400px]' : 'max-w-none'">
{{ $slot }}
</div>
</main>
@if ($fullBleed ?? false)
{{-- Full-bleed pages (canvases) fill the space between the top bar and the sidebar. --}}
<main class="app-main app-main-full relative min-h-0 flex-1 bg-neutral-50 dark:bg-app lg:pt-12"
:class="[collapsed ? 'lg:ml-16' : 'lg:ml-56', sidebarReady ? 'transition-[margin] duration-200' : '']">
<div class="relative h-full">
{{ $slot }}
</div>
</main>
@else
<main
class="app-main min-h-screen bg-neutral-50 dark:bg-app px-5 py-6 sm:px-8 lg:px-10 lg:pt-[calc(3rem+1.75rem)] lg:pb-10"
:class="[collapsed ? 'lg:ml-16' : 'lg:ml-56', sidebarReady ? 'transition-[margin] duration-200' : '', pageWidth === 'centered' ? '' : 'app-main-wide']">
<div class="w-full" :class="pageWidth === 'centered' ? 'mx-auto max-w-[1400px]' : 'max-w-none'">
{{ $slot }}
</div>
</main>
@endif
</div>
@endauth
@endsection
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more