perf(breadcrumb): optimize queries and simplify navigation to fix OOM (#9048)

This commit is contained in:
Andras Bacsai 2026-03-20 16:16:10 +01:00 committed by GitHub
commit 93a8fcd8a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 167 additions and 822 deletions

View file

@ -13,33 +13,33 @@ class Index extends Component
public Environment $environment;
public Collection $applications;
public Collection $postgresqls;
public Collection $redis;
public Collection $mongodbs;
public Collection $mysqls;
public Collection $mariadbs;
public Collection $keydbs;
public Collection $dragonflies;
public Collection $clickhouses;
public Collection $services;
public Collection $allProjects;
public Collection $allEnvironments;
public array $parameters;
public function mount()
protected Collection $applications;
protected Collection $postgresqls;
protected Collection $redis;
protected Collection $mongodbs;
protected Collection $mysqls;
protected Collection $mariadbs;
protected Collection $keydbs;
protected Collection $dragonflies;
protected Collection $clickhouses;
protected Collection $services;
public function mount(): void
{
$this->applications = $this->postgresqls = $this->redis = $this->mongodbs = $this->mysqls = $this->mariadbs = $this->keydbs = $this->dragonflies = $this->clickhouses = $this->services = collect();
$this->parameters = get_route_parameters();
@ -55,31 +55,23 @@ public function mount()
$this->project = $project;
// Load projects and environments for breadcrumb navigation (avoids inline queries in view)
// Load projects and environments for breadcrumb navigation
$this->allProjects = Project::ownedByCurrentTeamCached();
$this->allEnvironments = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->with([
'applications.additional_servers',
'applications.destination.server',
'services',
'services.destination.server',
'postgresqls',
'postgresqls.destination.server',
'redis',
'redis.destination.server',
'mongodbs',
'mongodbs.destination.server',
'mysqls',
'mysqls.destination.server',
'mariadbs',
'mariadbs.destination.server',
'keydbs',
'keydbs.destination.server',
'dragonflies',
'dragonflies.destination.server',
'clickhouses',
'clickhouses.destination.server',
])->get();
'applications:id,uuid,name,environment_id',
'services:id,uuid,name,environment_id',
'postgresqls:id,uuid,name,environment_id',
'redis:id,uuid,name,environment_id',
'mongodbs:id,uuid,name,environment_id',
'mysqls:id,uuid,name,environment_id',
'mariadbs:id,uuid,name,environment_id',
'keydbs:id,uuid,name,environment_id',
'dragonflies:id,uuid,name,environment_id',
'clickhouses:id,uuid,name,environment_id',
])
->get();
$this->environment = $environment->loadCount([
'applications',
@ -94,11 +86,9 @@ public function mount()
'services',
]);
// Eager load all relationships for applications including nested ones
// Eager load relationships for applications
$this->applications = $this->environment->applications()->with([
'tags',
'additional_servers.settings',
'additional_networks',
'destination.server.settings',
'settings',
])->get()->sortBy('name');
@ -160,6 +150,49 @@ public function mount()
public function render()
{
return view('livewire.project.resource.index');
return view('livewire.project.resource.index', [
'applications' => $this->applications,
'postgresqls' => $this->postgresqls,
'redis' => $this->redis,
'mongodbs' => $this->mongodbs,
'mysqls' => $this->mysqls,
'mariadbs' => $this->mariadbs,
'keydbs' => $this->keydbs,
'dragonflies' => $this->dragonflies,
'clickhouses' => $this->clickhouses,
'services' => $this->services,
'applicationsJs' => $this->toSearchableArray($this->applications),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls),
'redisJs' => $this->toSearchableArray($this->redis),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs),
'mysqlsJs' => $this->toSearchableArray($this->mysqls),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs),
'keydbsJs' => $this->toSearchableArray($this->keydbs),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses),
'servicesJs' => $this->toSearchableArray($this->services),
]);
}
private function toSearchableArray(Collection $items): array
{
return $items->map(fn ($item) => [
'uuid' => $item->uuid,
'name' => $item->name,
'fqdn' => $item->fqdn ?? null,
'description' => $item->description ?? null,
'status' => $item->status ?? '',
'server_status' => $item->server_status ?? null,
'hrefLink' => $item->hrefLink ?? '',
'destination' => [
'server' => [
'name' => $item->destination?->server?->name ?? 'Unknown',
],
],
'tags' => $item->tags->map(fn ($tag) => [
'id' => $tag->id,
'name' => $tag->name,
])->values()->toArray(),
])->values()->toArray();
}
}

View file

@ -12,17 +12,18 @@
$projects = $projects ?? Project::ownedByCurrentTeamCached();
$environments = $environments ?? $resource->environment->project
->environments()
->select('id', 'uuid', 'name', 'project_id')
->with([
'applications',
'services',
'postgresqls',
'redis',
'mongodbs',
'mysqls',
'mariadbs',
'keydbs',
'dragonflies',
'clickhouses',
'applications:id,uuid,name,environment_id',
'services:id,uuid,name,environment_id',
'postgresqls:id,uuid,name,environment_id',
'redis:id,uuid,name,environment_id',
'mongodbs:id,uuid,name,environment_id',
'mysqls:id,uuid,name,environment_id',
'mariadbs:id,uuid,name,environment_id',
'keydbs:id,uuid,name,environment_id',
'dragonflies:id,uuid,name,environment_id',
'clickhouses:id,uuid,name,environment_id',
])
->get();
$currentProjectUuid = data_get($resource, 'environment.project.uuid');
@ -63,7 +64,7 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
</li>
<!-- Environment Level -->
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, activeRes: null, resPositions: {}, activeMenuEnv: null, menuPositions: {}, closeTimeout: null, envTimeout: null, resTimeout: null, menuTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; } }, open() { clearTimeout(this.closeTimeout); this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false; this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout); clearTimeout(this.envTimeout); this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openRes(id) { clearTimeout(this.envTimeout); clearTimeout(this.resTimeout); this.activeRes = id }, closeRes() { this.resTimeout = setTimeout(() => { this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openMenu(id) { clearTimeout(this.resTimeout); clearTimeout(this.menuTimeout); this.activeMenuEnv = id }, closeMenu() { this.menuTimeout = setTimeout(() => { this.activeMenuEnv = null; }, 100) } }">
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, closeTimeout: null, envTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null; } }, open() { clearTimeout(this.closeTimeout); this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false; this.activeEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout); clearTimeout(this.envTimeout); this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null; }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()"
@mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
@ -80,17 +81,18 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
</svg>
</button>
<!-- Environment Dropdown Container -->
<!-- Environment Dropdown -->
<div x-show="envOpen" @click.outside="close()" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95" class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]" x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
x-transition:leave-end="opacity-0 scale-95"
class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]"
x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
<!-- Environment List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($environments as $environment)
@php
// Use pre-loaded relations instead of databases() method to avoid N+1 queries
$envDatabases = collect()
->merge($environment->postgresqls ?? collect())
->merge($environment->redis ?? collect())
@ -101,26 +103,17 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor
->merge($environment->dragonflies ?? collect())
->merge($environment->clickhouses ?? collect());
$envResources = collect()
->merge(
$environment->applications->map(
fn($app) => ['type' => 'application', 'resource' => $app],
),
)
->merge(
$envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]),
)
->merge(
$environment->services->map(
fn($svc) => ['type' => 'service', 'resource' => $svc],
),
);
->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]))
->sortBy(fn($item) => strtolower($item['resource']->name));
@endphp
<div @mouseenter="openEnv('{{ $environment->uuid }}'); envPositions['{{ $environment->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeEnv()">
<a href="{{ route('project.resource.index', [
'environment_uuid' => $environment->uuid,
'project_uuid' => $currentProjectUuid,
]) }}" {{ wireNavigate() }}
'environment_uuid' => $environment->uuid,
'project_uuid' => $currentProjectUuid,
]) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $environment->uuid === $currentEnvironmentUuid ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $environment->name }}">
<span class="truncate">{{ $environment->name }}</span>
@ -150,31 +143,29 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover
<!-- Resources Sub-dropdown (2nd level) -->
@foreach ($environments as $environment)
@php
$envDatabases = collect()
->merge($environment->postgresqls ?? collect())
->merge($environment->redis ?? collect())
->merge($environment->mongodbs ?? collect())
->merge($environment->mysqls ?? collect())
->merge($environment->mariadbs ?? collect())
->merge($environment->keydbs ?? collect())
->merge($environment->dragonflies ?? collect())
->merge($environment->clickhouses ?? collect());
$envResources = collect()
->merge(
$environment->applications->map(
fn($app) => ['type' => 'application', 'resource' => $app],
),
)
->merge(
$environment
->databases()
->map(fn($db) => ['type' => 'database', 'resource' => $db]),
)
->merge(
$environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]),
);
->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
@if ($envResources->count() > 0)
<div x-show="activeEnv === '{{ $environment->uuid }}'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openEnv('{{ $environment->uuid }}')"
@mouseleave="closeEnv()"
@mouseenter="openEnv('{{ $environment->uuid }}')" @mouseleave="closeEnv()"
:style="'position: absolute; left: 100%; top: ' + (envPositions['{{ $environment->uuid }}'] || 0) + 'px; z-index: 30;'"
class="flex flex-col sm:flex-row items-start pl-1">
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
class="relative w-56 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
@ -197,226 +188,14 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor
]),
};
$isCurrentResource = $res->uuid === $currentResourceUuid;
// Use loaded relation count if available, otherwise check additional_servers_count attribute
$resHasMultipleServers = $resType === 'application' && method_exists($res, 'additional_servers') &&
($res->relationLoaded('additional_servers') ? $res->additional_servers->count() > 0 : ($res->additional_servers_count ?? 0) > 0);
$resServerName = $resHasMultipleServers ? null : data_get($res, 'destination.server.name');
@endphp
<div @mouseenter="openRes('{{ $environment->uuid }}-{{ $res->uuid }}'); resPositions['{{ $environment->uuid }}-{{ $res->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeRes()">
<a href="{{ $resRoute }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $isCurrentResource ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $res->name }}{{ $resServerName ? ' ('.$resServerName.')' : '' }}">
<span class="truncate">{{ $res->name }}@if($resServerName) <span class="text-xs text-neutral-400">({{ $resServerName }})</span>@endif</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ $resRoute }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $isCurrentResource ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $res->name }}">
{{ $res->name }}
</a>
@endforeach
</div>
<!-- Main Menu Sub-dropdown (3rd level) -->
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
$res = $envResource['resource'];
$resParams = [
'project_uuid' => $currentProjectUuid,
'environment_uuid' => $environment->uuid,
];
if ($resType === 'application') {
$resParams['application_uuid'] = $res->uuid;
} elseif ($resType === 'service') {
$resParams['service_uuid'] = $res->uuid;
} else {
$resParams['database_uuid'] = $res->uuid;
}
$resKey = $environment->uuid . '-' . $res->uuid;
@endphp
<div x-show="activeRes === '{{ $resKey }}'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openRes('{{ $resKey }}')"
@mouseleave="closeRes()"
:style="'position: absolute; left: 100%; top: ' + (resPositions['{{ $resKey }}'] || 0) + 'px; z-index: 40;'"
class="flex flex-col sm:flex-row items-start pl-1">
<!-- Main Menu List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200">
@if ($resType === 'application')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.application.configuration', $resParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Deployments</a>
<a href="{{ route('project.application.logs', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.application.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@elseif ($resType === 'service')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.service.configuration', $resParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.service.logs', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.service.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@else
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.database.configuration', $resParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.database.logs', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.database.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@if (
$res->getMorphClass() === 'App\Models\StandalonePostgresql' ||
$res->getMorphClass() === 'App\Models\StandaloneMongodb' ||
$res->getMorphClass() === 'App\Models\StandaloneMysql' ||
$res->getMorphClass() === 'App\Models\StandaloneMariadb')
<a href="{{ route('project.database.backup.index', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Backups</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu (4th level) -->
<div x-show="activeMenuEnv === '{{ $resKey }}-config'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openMenu('{{ $resKey }}-config')"
@mouseleave="closeMenu()"
:style="'position: absolute; left: 100%; top: ' + (menuPositions['{{ $resKey }}-config'] || 0) + 'px; z-index: 50;'"
class="pl-1">
<div class="w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($resType === 'application')
<a href="{{ route('project.application.configuration', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.application.persistent-storage', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.application.source', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.application.webhooks', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Preview
Deployments</a>
<a href="{{ route('project.application.healthcheck', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.application.resource-operations', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.application.metrics', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@elseif ($resType === 'service')
<a href="{{ route('project.service.configuration', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.service.storages', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.service.webhooks', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.service.tags', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@else
<a href="{{ route('project.database.configuration', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.database.servers', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.database.webhooks', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.database.resource-operations', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.database.metrics', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@endif
</div>
</div>
</div>
@endforeach
</div>
@endif
@endforeach
@ -431,7 +210,6 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
$isApplication = $resourceType === 'App\Models\Application';
$isService = $resourceType === 'App\Models\Service';
$isDatabase = str_contains($resourceType, 'Database') || str_contains($resourceType, 'Standalone');
// Use loaded relation count if available, otherwise check additional_servers_count attribute
$hasMultipleServers = $isApplication && method_exists($resource, 'additional_servers') &&
($resource->relationLoaded('additional_servers') ? $resource->additional_servers->count() > 0 : ($resource->additional_servers_count ?? 0) > 0);
$serverName = $hasMultipleServers ? null : data_get($resource, 'destination.server.name');
@ -447,221 +225,16 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
$routeParams['database_uuid'] = $resourceUuid;
}
@endphp
<li class="inline-flex items-center" x-data="{ resourceOpen: false, activeMenu: null, menuPosition: 0, closeTimeout: null, menuTimeout: null, toggle() { this.resourceOpen = !this.resourceOpen; if (!this.resourceOpen) { this.activeMenu = null; } }, open() { clearTimeout(this.closeTimeout); this.resourceOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.resourceOpen = false; this.activeMenu = null; }, 100) }, openMenu(id) { clearTimeout(this.closeTimeout); clearTimeout(this.menuTimeout); this.activeMenu = id }, closeMenu() { this.menuTimeout = setTimeout(() => { this.activeMenu = null; }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()"
@mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ $isApplication
? route('project.application.configuration', $routeParams)
: ($isService
? route('project.service.configuration', $routeParams)
: route('project.database.configuration', $routeParams)) }}"
title="{{ data_get($resource, 'name') }}{{ $serverName ? ' ('.$serverName.')' : '' }}">
{{ data_get($resource, 'name') }}@if($serverName) <span class="text-xs text-neutral-400">({{ $serverName }})</span>@endif
</a>
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-down': resourceOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7">
</path>
</svg>
</button>
<!-- Resource Dropdown Container -->
<div x-show="resourceOpen" @click.outside="close()" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]" x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
<!-- Main Menu List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200">
@if ($isApplication)
<!-- Application Main Menus -->
<div @mouseenter="openMenu('config'); menuPosition = $el.offsetTop" @mouseleave="closeMenu()">
<a href="{{ route('project.application.configuration', $routeParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Deployments
</a>
<a href="{{ route('project.application.logs', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Logs
</a>
@can('canAccessTerminal')
<a href="{{ route('project.application.command', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@elseif ($isService)
<!-- Service Main Menus -->
<div @mouseenter="openMenu('config'); menuPosition = $el.offsetTop" @mouseleave="closeMenu()">
<a href="{{ route('project.service.configuration', $routeParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.service.logs', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Logs
</a>
@can('canAccessTerminal')
<a href="{{ route('project.service.command', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@else
<!-- Database Main Menus -->
<div @mouseenter="openMenu('config'); menuPosition = $el.offsetTop" @mouseleave="closeMenu()">
<a href="{{ route('project.database.configuration', $routeParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.database.logs', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Logs
</a>
@can('canAccessTerminal')
<a href="{{ route('project.database.command', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@if (
$resourceType === 'App\Models\StandalonePostgresql' ||
$resourceType === 'App\Models\StandaloneMongodb' ||
$resourceType === 'App\Models\StandaloneMysql' ||
$resourceType === 'App\Models\StandaloneMariadb')
<a href="{{ route('project.database.backup.index', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Backups
</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu -->
<div x-show="activeMenu === 'config'" x-cloak x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openMenu('config')"
@mouseleave="closeMenu()"
:style="'position: absolute; left: 100%; top: ' + menuPosition + 'px; z-index: 50;'"
class="pl-1">
<div class="w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($isApplication)
<a href="{{ route('project.application.configuration', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.application.persistent-storage', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.application.source', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.application.webhooks', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Preview
Deployments</a>
<a href="{{ route('project.application.healthcheck', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.application.resource-operations', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.application.metrics', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@elseif ($isService)
<a href="{{ route('project.service.configuration', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.service.storages', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.service.webhooks', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.service.tags', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@else
<a href="{{ route('project.database.configuration', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.database.servers', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.database.webhooks', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.database.resource-operations', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.database.metrics', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@endif
</div>
</div>
</div>
</div>
<li class="inline-flex items-center mr-2">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ $isApplication
? route('project.application.configuration', $routeParams)
: ($isService
? route('project.service.configuration', $routeParams)
: route('project.database.configuration', $routeParams)) }}"
title="{{ data_get($resource, 'name') }}{{ $serverName ? ' ('.$serverName.')' : '' }}">
{{ data_get($resource, 'name') }}@if($serverName) <span class="text-xs text-neutral-400">({{ $serverName }})</span>@endif
</a>
</li>
<!-- Current Section Status -->

View file

@ -60,36 +60,13 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
</div>
</div>
</li>
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, activeRes: null, resPositions: {}, activeMenuEnv: null, menuPositions: {}, closeTimeout: null, envTimeout: null, resTimeout: null, menuTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null;
this.activeRes = null;
this.activeMenuEnv = null; } }, open() { clearTimeout(this.closeTimeout);
this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false;
this.activeEnv = null;
this.activeRes = null;
this.activeMenuEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout);
clearTimeout(this.envTimeout);
this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null;
this.activeRes = null;
this.activeMenuEnv = null; }, 100) }, openRes(id) { clearTimeout(this.envTimeout);
clearTimeout(this.resTimeout);
this.activeRes = id }, closeRes() { this.resTimeout = setTimeout(() => { this.activeRes = null;
this.activeMenuEnv = null; }, 100) }, openMenu(id) { clearTimeout(this.resTimeout);
clearTimeout(this.menuTimeout);
this.activeMenuEnv = id }, closeMenu() { this.menuTimeout = setTimeout(() => { this.activeMenuEnv = null; }, 100) } }">
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, closeTimeout: null, envTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null; } }, open() { clearTimeout(this.closeTimeout); this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false; this.activeEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout); clearTimeout(this.envTimeout); this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null; }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()" @mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ route('project.resource.index', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => $environment->uuid]) }}">
{{ $environment->name }}
</a>
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-90': envOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7">
</path>
</svg>
</button>
<!-- Environment Dropdown Container -->
<div x-show="envOpen" @click.outside="close()"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
@ -103,26 +80,25 @@ class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]"
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($allEnvironments as $env)
@php
$envDatabases = collect()
->merge($env->postgresqls ?? collect())
->merge($env->redis ?? collect())
->merge($env->mongodbs ?? collect())
->merge($env->mysqls ?? collect())
->merge($env->mariadbs ?? collect())
->merge($env->keydbs ?? collect())
->merge($env->dragonflies ?? collect())
->merge($env->clickhouses ?? collect());
$envResources = collect()
->merge(
$env->applications->map(
fn($app) => ['type' => 'application', 'resource' => $app],
),
)
->merge(
$env
->databases()
->map(fn($db) => ['type' => 'database', 'resource' => $db]),
)
->merge(
$env->services->map(
fn($svc) => ['type' => 'service', 'resource' => $svc],
),
);
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]))
->sortBy(fn($item) => strtolower($item['resource']->name));
@endphp
<div @mouseenter="openEnv('{{ $env->uuid }}'); envPositions['{{ $env->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeEnv()">
<a href="{{ route('project.resource.index', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => $env->uuid]) }}"
{{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $env->uuid === $environment->uuid ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $env->name }}">
<span class="truncate">{{ $env->name }}</span>
@ -153,7 +129,6 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover
<!-- Resources Sub-dropdown (2nd level) -->
@foreach ($allEnvironments as $env)
@php
// Use pre-loaded relations instead of databases() method to avoid N+1 queries
$envDatabases = collect()
->merge($env->postgresqls ?? collect())
->merge($env->redis ?? collect())
@ -164,28 +139,19 @@ class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover
->merge($env->dragonflies ?? collect())
->merge($env->clickhouses ?? collect());
$envResources = collect()
->merge(
$env->applications->map(
fn($app) => ['type' => 'application', 'resource' => $app],
),
)
->merge(
$envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]),
)
->merge(
$env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]),
);
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
@if ($envResources->count() > 0)
<div x-show="activeEnv === '{{ $env->uuid }}'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openEnv('{{ $env->uuid }}')" @mouseleave="closeEnv()"
:style="'position: absolute; left: 100%; top: ' + (envPositions[
'{{ $env->uuid }}'] || 0) + 'px; z-index: 30;'"
:style="'position: absolute; left: 100%; top: ' + (envPositions['{{ $env->uuid }}'] || 0) + 'px; z-index: 30;'"
class="flex flex-col sm:flex-row items-start pl-1">
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
class="relative w-56 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
@ -207,241 +173,14 @@ class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 bor
'database_uuid' => $res->uuid,
]),
};
// Use loaded relation to check additional_servers (avoids N+1 query)
$resHasMultipleServers =
$resType === 'application' &&
method_exists($res, 'additional_servers') &&
($res->relationLoaded('additional_servers') ? $res->additional_servers->count() > 0 : false);
$resServerName = $resHasMultipleServers
? null
: data_get($res, 'destination.server.name');
@endphp
<div @mouseenter="openRes('{{ $env->uuid }}-{{ $res->uuid }}'); resPositions['{{ $env->uuid }}-{{ $res->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeRes()">
<a href="{{ $resRoute }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200"
title="{{ $res->name }}{{ $resServerName ? ' (' . $resServerName . ')' : '' }}">
<span class="truncate">{{ $res->name }}@if ($resServerName)
<span
class="text-xs text-neutral-400">({{ $resServerName }})</span>
@endif
</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ $resRoute }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200"
title="{{ $res->name }}">
{{ $res->name }}
</a>
@endforeach
</div>
<!-- Main Menu Sub-dropdown (3rd level) -->
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
$res = $envResource['resource'];
$resParams = [
'project_uuid' => $project->uuid,
'environment_uuid' => $env->uuid,
];
if ($resType === 'application') {
$resParams['application_uuid'] = $res->uuid;
} elseif ($resType === 'service') {
$resParams['service_uuid'] = $res->uuid;
} else {
$resParams['database_uuid'] = $res->uuid;
}
$resKey = $env->uuid . '-' . $res->uuid;
@endphp
<div x-show="activeRes === '{{ $resKey }}'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@mouseenter="openRes('{{ $resKey }}')" @mouseleave="closeRes()"
:style="'position: absolute; left: 100%; top: ' + (resPositions[
'{{ $resKey }}'] || 0) + 'px; z-index: 40;'"
class="flex flex-col sm:flex-row items-start pl-1">
<!-- Main Menu List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200">
@if ($resType === 'application')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.application.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Deployments</a>
<a href="{{ route('project.application.logs', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.application.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@elseif ($resType === 'service')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.service.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.service.logs', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.service.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@else
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.database.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.database.logs', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.database.command', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@if (
$res->getMorphClass() === 'App\Models\StandalonePostgresql' ||
$res->getMorphClass() === 'App\Models\StandaloneMongodb' ||
$res->getMorphClass() === 'App\Models\StandaloneMysql' ||
$res->getMorphClass() === 'App\Models\StandaloneMariadb')
<a href="{{ route('project.database.backup.index', $resParams) }}"
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Backups</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu (4th level) -->
<div x-show="activeMenuEnv === '{{ $resKey }}-config'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@mouseenter="openMenu('{{ $resKey }}-config')"
@mouseleave="closeMenu()"
:style="'position: absolute; left: 100%; top: ' + (menuPositions[
'{{ $resKey }}-config'] || 0) + 'px; z-index: 50;'"
class="pl-1">
<div
class="w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($resType === 'application')
<a href="{{ route('project.application.configuration', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.application.persistent-storage', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.application.source', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.application.webhooks', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Preview
Deployments</a>
<a href="{{ route('project.application.healthcheck', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.application.resource-operations', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.application.metrics', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@elseif ($resType === 'service')
<a href="{{ route('project.service.configuration', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.service.storages', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.service.webhooks', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.service.tags', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@else
<a href="{{ route('project.database.configuration', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.database.servers', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.database.webhooks', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.database.resource-operations', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.database.metrics', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $resParams) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@endif
</div>
</div>
</div>
@endforeach
</div>
@endif
@endforeach
@ -656,16 +395,16 @@ function sortFn(a, b) {
function searchComponent() {
return {
search: '',
applications: @js($applications),
postgresqls: @js($postgresqls),
redis: @js($redis),
mongodbs: @js($mongodbs),
mysqls: @js($mysqls),
mariadbs: @js($mariadbs),
keydbs: @js($keydbs),
dragonflies: @js($dragonflies),
clickhouses: @js($clickhouses),
services: @js($services),
applications: @js($applicationsJs),
postgresqls: @js($postgresqlsJs),
redis: @js($redisJs),
mongodbs: @js($mongodbsJs),
mysqls: @js($mysqlsJs),
mariadbs: @js($mariadbsJs),
keydbs: @js($keydbsJs),
dragonflies: @js($dragonfliesJs),
clickhouses: @js($clickhousesJs),
services: @js($servicesJs),
filterAndSort(items) {
if (this.search === '') {
return Object.values(items).sort(sortFn);