mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Merge remote-tracking branch 'origin/main' into next
This commit is contained in:
commit
41a264a2be
46 files changed
+1008
-366
No files matched your search
@@ -11,7 +11,6 @@ use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartService
|
||||
{
|
||||
@@ -61,7 +60,7 @@ class StartService
|
||||
if (data_get($service, 'connect_to_docker_network')) {
|
||||
$compose = data_get($service, 'docker_compose', []);
|
||||
$safeNetwork = escapeshellarg($service->destination->network);
|
||||
$serviceNames = data_get(Yaml::parse($compose), 'services', []);
|
||||
$serviceNames = data_get(parseDockerComposeYaml($compose), 'services', []);
|
||||
foreach ($serviceNames as $serviceName => $serviceConfig) {
|
||||
$containerName = escapeshellarg("{$serviceName}-{$service->uuid}");
|
||||
$commands[] = "docker network connect --alias {$containerName} {$safeNetwork} {$containerName} >/dev/null 2>&1 || true";
|
||||
|
||||
@@ -36,7 +36,6 @@ use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ApplicationsController extends Controller
|
||||
{
|
||||
@@ -2787,7 +2786,7 @@ class ApplicationsController extends Controller
|
||||
$dockerComposeDomainsResponse = null;
|
||||
if ($isCompose) {
|
||||
try {
|
||||
$compose = Yaml::parse($application->docker_compose_raw ?? '');
|
||||
$compose = parseDockerComposeYaml($application->docker_compose_raw ?? '');
|
||||
} catch (\Throwable) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -3519,7 +3518,7 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$yaml = Yaml::parse($application->docker_compose_raw);
|
||||
$yaml = parseDockerComposeYaml($application->docker_compose_raw);
|
||||
$services = data_get($yaml, 'services', []);
|
||||
$existingDockerComposeDomains = json_decode($application->docker_compose_domains ?? '[]', true) ?? [];
|
||||
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson, $existingDockerComposeDomains) {
|
||||
|
||||
@@ -26,7 +26,6 @@ use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServicesController extends Controller
|
||||
{
|
||||
@@ -729,7 +728,7 @@ class ServicesController extends Controller
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
Yaml::parse($dockerComposeRaw);
|
||||
parseDockerComposeYaml($dockerComposeRaw);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
try {
|
||||
@@ -1278,7 +1277,7 @@ class ServicesController extends Controller
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
Yaml::parse($dockerComposeRaw);
|
||||
parseDockerComposeYaml($dockerComposeRaw);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
try {
|
||||
|
||||
@@ -163,7 +163,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private $docker_compose;
|
||||
|
||||
private $docker_compose_base64;
|
||||
private ?string $generated_docker_compose = null;
|
||||
|
||||
private ?string $nixpacks_plan = null;
|
||||
|
||||
@@ -840,11 +840,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
$yaml = Yaml::dump(convertToArray($composeFile), 10);
|
||||
}
|
||||
$this->docker_compose_base64 = base64_encode($yaml);
|
||||
$this->generated_docker_compose = $yaml;
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"),
|
||||
"docker exec -i {$this->deployment_uuid} tee {$this->workdir}{$this->docker_compose_location} > /dev/null",
|
||||
'input' => $yaml,
|
||||
'hidden' => true,
|
||||
'skip_command_log' => true,
|
||||
]);
|
||||
|
||||
// Modify Dockerfiles for ARGs and build secrets
|
||||
@@ -1262,7 +1262,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->server = $this->build_server;
|
||||
}
|
||||
}
|
||||
if (isset($this->docker_compose_base64)) {
|
||||
if (isset($this->generated_docker_compose)) {
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->mainServer;
|
||||
}
|
||||
@@ -1283,8 +1283,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
"mkdir -p $mainDir",
|
||||
],
|
||||
[
|
||||
"echo '{$this->docker_compose_base64}' | base64 -d | tee $composeFileName > /dev/null",
|
||||
'skip_command_log' => true,
|
||||
"tee $composeFileName > /dev/null",
|
||||
'input' => $this->generated_docker_compose,
|
||||
],
|
||||
[
|
||||
"echo '{$readme}' | tee $mainDir/README.md > /dev/null",
|
||||
@@ -1726,9 +1726,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
// Generate SERVICE_NAME for dockercompose services from processed compose
|
||||
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
||||
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
|
||||
} else {
|
||||
$dockerCompose = Yaml::parse($this->application->docker_compose);
|
||||
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose);
|
||||
}
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
foreach ($services as $serviceName => $_) {
|
||||
@@ -1796,7 +1796,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
// Generate SERVICE_NAME for dockercompose services
|
||||
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||
$rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
|
||||
$rawServices = data_get($rawDockerCompose, 'services', []);
|
||||
foreach ($rawServices as $rawServiceName => $_) {
|
||||
$envs->push('SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
|
||||
@@ -2035,9 +2035,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if ($this->pull_request_id === 0) {
|
||||
// Generate SERVICE_NAME for dockercompose services from processed compose
|
||||
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
||||
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
|
||||
} else {
|
||||
$dockerCompose = Yaml::parse($this->application->docker_compose);
|
||||
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose);
|
||||
}
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
foreach ($services as $serviceName => $_) {
|
||||
@@ -2061,7 +2061,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
} else {
|
||||
// Generate SERVICE_NAME for preview deployments
|
||||
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||
$rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
|
||||
$rawServices = data_get($rawDockerCompose, 'services', []);
|
||||
foreach ($rawServices as $rawServiceName => $_) {
|
||||
$envs_dict['SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
|
||||
@@ -4109,8 +4109,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
|
||||
$this->docker_compose = Yaml::dump($docker_compose, 10);
|
||||
$this->docker_compose_base64 = base64_encode($this->docker_compose);
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}/docker-compose.yaml > /dev/null"), 'hidden' => true, 'skip_command_log' => true]);
|
||||
$this->generated_docker_compose = $this->docker_compose;
|
||||
$this->execute_remote_command([
|
||||
"docker exec -i {$this->deployment_uuid} tee {$this->workdir}/docker-compose.yaml > /dev/null",
|
||||
'input' => $this->docker_compose,
|
||||
'hidden' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
|
||||
+307
-137
@@ -3,7 +3,10 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Environment;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
@@ -16,6 +19,7 @@ use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\StandaloneSqlite;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -252,7 +256,7 @@ class GlobalSearch extends Component
|
||||
// Try to get from Redis cache first
|
||||
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
|
||||
|
||||
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
|
||||
$resources = Cache::remember($cacheKey, 300, function () {
|
||||
$items = collect();
|
||||
$team = auth()->user()->currentTeam();
|
||||
|
||||
@@ -617,143 +621,8 @@ class GlobalSearch extends Component
|
||||
];
|
||||
});
|
||||
|
||||
// Add navigation routes
|
||||
$navigation = collect([
|
||||
[
|
||||
'name' => 'Dashboard',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Go to main dashboard',
|
||||
'link' => route('dashboard'),
|
||||
'search_text' => 'dashboard home main overview',
|
||||
],
|
||||
[
|
||||
'name' => 'Servers',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View all servers',
|
||||
'link' => route('server.index'),
|
||||
'search_text' => 'servers all list view',
|
||||
],
|
||||
[
|
||||
'name' => 'Projects',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View all projects',
|
||||
'link' => route('project.index'),
|
||||
'search_text' => 'projects all list view',
|
||||
],
|
||||
[
|
||||
'name' => 'Destinations',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View all destinations',
|
||||
'link' => route('destination.index'),
|
||||
'search_text' => 'destinations docker networks',
|
||||
],
|
||||
[
|
||||
'name' => 'Security',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage private keys and API tokens',
|
||||
'link' => route('security.private-key.index'),
|
||||
'search_text' => 'security private keys ssh api tokens cloud-init scripts',
|
||||
],
|
||||
[
|
||||
'name' => 'Cloud-Init Scripts',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage reusable cloud-init scripts',
|
||||
'link' => route('security.cloud-init-scripts'),
|
||||
'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup',
|
||||
],
|
||||
[
|
||||
'name' => 'Sources',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage GitHub apps and Git sources',
|
||||
'link' => route('source.all'),
|
||||
'search_text' => 'sources github apps git repositories',
|
||||
],
|
||||
[
|
||||
'name' => 'Storages',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage S3 storage for backups',
|
||||
'link' => route('storage.index'),
|
||||
'search_text' => 'storages s3 backups',
|
||||
],
|
||||
[
|
||||
'name' => 'Shared Variables',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View all shared variables',
|
||||
'link' => route('shared-variables.index'),
|
||||
'search_text' => 'shared variables environment all',
|
||||
],
|
||||
[
|
||||
'name' => 'Team Shared Variables',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage team-wide shared variables',
|
||||
'link' => route('shared-variables.team.index'),
|
||||
'search_text' => 'shared variables team environment',
|
||||
],
|
||||
[
|
||||
'name' => 'Project Shared Variables',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage project shared variables',
|
||||
'link' => route('shared-variables.project.index'),
|
||||
'search_text' => 'shared variables project environment',
|
||||
],
|
||||
[
|
||||
'name' => 'Environment Shared Variables',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage environment shared variables',
|
||||
'link' => route('shared-variables.environment.index'),
|
||||
'search_text' => 'shared variables environment',
|
||||
],
|
||||
[
|
||||
'name' => 'Tags',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View resources by tags',
|
||||
'link' => route('tags.show'),
|
||||
'search_text' => 'tags labels organize',
|
||||
],
|
||||
[
|
||||
'name' => 'Terminal',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Access server terminal',
|
||||
'link' => route('terminal'),
|
||||
'search_text' => 'terminal ssh console shell command line',
|
||||
],
|
||||
[
|
||||
'name' => 'Profile',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage your profile and preferences',
|
||||
'link' => route('profile'),
|
||||
'search_text' => 'profile account user settings preferences',
|
||||
],
|
||||
[
|
||||
'name' => 'Team',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage team members and settings',
|
||||
'link' => route('team.index'),
|
||||
'search_text' => 'team settings members users invitations',
|
||||
],
|
||||
[
|
||||
'name' => 'Notifications',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure email, Discord, Telegram notifications',
|
||||
'link' => route('notifications.email'),
|
||||
'search_text' => 'notifications alerts email discord telegram slack pushover',
|
||||
],
|
||||
]);
|
||||
|
||||
// Add instance settings only for self-hosted and root team
|
||||
if (! isCloud() && $team->id === 0) {
|
||||
$navigation->push([
|
||||
'name' => 'Settings',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Instance settings and configuration',
|
||||
'link' => route('settings.index'),
|
||||
'search_text' => 'settings configuration instance',
|
||||
]);
|
||||
}
|
||||
|
||||
// Merge all collections
|
||||
$items = $items->merge($navigation)
|
||||
->merge($applications)
|
||||
$items = $items->merge($applications)
|
||||
->merge($services)
|
||||
->merge($databases)
|
||||
->merge($servers)
|
||||
@@ -762,6 +631,307 @@ class GlobalSearch extends Component
|
||||
|
||||
return $items->toArray();
|
||||
});
|
||||
|
||||
// Navigation depends on the user's permissions, so it is not part of the team-wide cache
|
||||
$this->allSearchableItems = $this->navigationItems()->merge($resources)->values()->all();
|
||||
}
|
||||
|
||||
private function navigationItems(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
$team = $user->currentTeam();
|
||||
|
||||
// Add navigation routes
|
||||
$navigation = collect([
|
||||
[
|
||||
'name' => 'Dashboard',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Go to main dashboard',
|
||||
'link' => route('dashboard'),
|
||||
'search_text' => 'dashboard home main overview',
|
||||
],
|
||||
[
|
||||
'name' => 'Servers',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View all servers',
|
||||
'link' => route('server.index'),
|
||||
'search_text' => 'servers all list view',
|
||||
],
|
||||
[
|
||||
'name' => 'Projects',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View all projects',
|
||||
'link' => route('project.index'),
|
||||
'search_text' => 'projects all list view',
|
||||
],
|
||||
[
|
||||
'name' => 'Destinations',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View all destinations',
|
||||
'link' => route('destination.index'),
|
||||
'search_text' => 'destinations docker networks',
|
||||
],
|
||||
[
|
||||
'name' => 'Security',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage private keys and API tokens',
|
||||
'link' => route('security.private-key.index'),
|
||||
'search_text' => 'security private keys ssh api tokens cloud-init scripts',
|
||||
],
|
||||
$user->can('viewAny', CloudInitScript::class) ? [
|
||||
'name' => 'Cloud-Init Scripts',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage reusable cloud-init scripts',
|
||||
'link' => route('security.cloud-init-scripts'),
|
||||
'search_text' => 'cloud-init scripts cloud init cloudinit initialization startup server setup',
|
||||
] : null,
|
||||
[
|
||||
'name' => 'Sources',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage GitHub apps and Git sources',
|
||||
'link' => route('source.all'),
|
||||
'search_text' => 'sources github apps git repositories',
|
||||
],
|
||||
[
|
||||
'name' => 'Storages',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage S3 storage for backups',
|
||||
'link' => route('storage.index'),
|
||||
'search_text' => 'storages s3 backups',
|
||||
],
|
||||
[
|
||||
'name' => 'Shared Variables',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View all shared variables',
|
||||
'link' => route('shared-variables.index'),
|
||||
'search_text' => 'shared variables environment all',
|
||||
],
|
||||
[
|
||||
'name' => 'Team Shared Variables',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage team-wide shared variables',
|
||||
'link' => route('shared-variables.team.index'),
|
||||
'search_text' => 'shared variables team environment',
|
||||
],
|
||||
[
|
||||
'name' => 'Project Shared Variables',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage project shared variables',
|
||||
'link' => route('shared-variables.project.index'),
|
||||
'search_text' => 'shared variables project environment',
|
||||
],
|
||||
[
|
||||
'name' => 'Environment Shared Variables',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage environment shared variables',
|
||||
'link' => route('shared-variables.environment.index'),
|
||||
'search_text' => 'shared variables environment',
|
||||
],
|
||||
[
|
||||
'name' => 'Tags',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View resources by tags',
|
||||
'link' => route('tags.show'),
|
||||
'search_text' => 'tags labels organize',
|
||||
],
|
||||
$user->can('canAccessTerminal') ? [
|
||||
'name' => 'Terminal',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Access server terminal',
|
||||
'link' => route('terminal'),
|
||||
'search_text' => 'terminal ssh console shell command line',
|
||||
] : null,
|
||||
[
|
||||
'name' => 'Profile',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage your profile and preferences',
|
||||
'link' => route('profile'),
|
||||
'search_text' => 'profile account user settings preferences',
|
||||
],
|
||||
[
|
||||
'name' => 'Team',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage team members and settings',
|
||||
'link' => route('team.index'),
|
||||
'search_text' => 'team settings members users invitations',
|
||||
],
|
||||
[
|
||||
'name' => 'Notifications',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure email, Discord, Telegram notifications',
|
||||
'link' => route('notifications.email'),
|
||||
'search_text' => 'notifications alerts email discord telegram slack pushover',
|
||||
],
|
||||
[
|
||||
'name' => 'Analytics',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View traffic analytics for your servers',
|
||||
'link' => route('analytics'),
|
||||
'search_text' => 'analytics traffic requests visitors metrics',
|
||||
],
|
||||
$user->isAdmin() ? [
|
||||
'name' => 'Registries',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage Docker registry credentials',
|
||||
'link' => route('registries.index'),
|
||||
'search_text' => 'registries docker registry credentials login private images',
|
||||
] : null,
|
||||
[
|
||||
'name' => 'Server Shared Variables',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage server shared variables',
|
||||
'link' => route('shared-variables.server.index'),
|
||||
'search_text' => 'shared variables server environment',
|
||||
],
|
||||
[
|
||||
'name' => 'Team Members',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage team members and invitations',
|
||||
'link' => route('team.member.index'),
|
||||
'search_text' => 'team members users invitations invite roles',
|
||||
],
|
||||
$user->isAdminOfTeam($team->id) ? [
|
||||
'name' => 'Team Audit Log',
|
||||
'type' => 'navigation',
|
||||
'description' => 'View team activity history',
|
||||
'link' => route('team.audit-log'),
|
||||
'search_text' => 'team audit log activity history events',
|
||||
] : null,
|
||||
isInstanceAdmin() ? [
|
||||
'name' => 'Team Admin View',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage all users of this instance',
|
||||
'link' => route('team.admin-view'),
|
||||
'search_text' => 'team admin view users instance delete',
|
||||
] : null,
|
||||
$user->can('delete', $team) ? [
|
||||
'name' => 'Team Danger Zone',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Delete this team',
|
||||
'link' => route('team.danger-zone'),
|
||||
'search_text' => 'team danger zone delete remove',
|
||||
] : null,
|
||||
[
|
||||
'name' => 'API Tokens',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage API tokens',
|
||||
'link' => route('security.api-tokens'),
|
||||
'search_text' => 'security api tokens personal access token',
|
||||
],
|
||||
$user->can('viewAny', CloudProviderToken::class) ? [
|
||||
'name' => 'Cloud Tokens',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage cloud provider tokens',
|
||||
'link' => route('security.cloud-tokens'),
|
||||
'search_text' => 'security cloud provider tokens hetzner digitalocean',
|
||||
] : null,
|
||||
$user->can('viewAny', IntegrationToken::class) ? [
|
||||
'name' => 'Integration Tokens',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage integration tokens',
|
||||
'link' => route('security.integration-tokens'),
|
||||
'search_text' => 'security integration tokens',
|
||||
] : null,
|
||||
[
|
||||
'name' => 'Discord Notifications',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure Discord notifications',
|
||||
'link' => route('notifications.discord'),
|
||||
'search_text' => 'notifications alerts discord webhook',
|
||||
],
|
||||
[
|
||||
'name' => 'Telegram Notifications',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure Telegram notifications',
|
||||
'link' => route('notifications.telegram'),
|
||||
'search_text' => 'notifications alerts telegram bot',
|
||||
],
|
||||
[
|
||||
'name' => 'Slack Notifications',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure Slack notifications',
|
||||
'link' => route('notifications.slack'),
|
||||
'search_text' => 'notifications alerts slack webhook',
|
||||
],
|
||||
[
|
||||
'name' => 'Pushover Notifications',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure Pushover notifications',
|
||||
'link' => route('notifications.pushover'),
|
||||
'search_text' => 'notifications alerts pushover',
|
||||
],
|
||||
[
|
||||
'name' => 'Webhook Notifications',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure webhook notifications',
|
||||
'link' => route('notifications.webhook'),
|
||||
'search_text' => 'notifications alerts webhook http',
|
||||
],
|
||||
[
|
||||
'name' => 'Appearance',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Change theme and display preferences',
|
||||
'link' => route('profile.appearance'),
|
||||
'search_text' => 'profile appearance theme dark light mode preferences',
|
||||
],
|
||||
isCloud() && $user->isAdmin() ? [
|
||||
'name' => 'Subscription',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Manage your subscription and billing',
|
||||
'link' => route('subscription.show'),
|
||||
'search_text' => 'subscription billing plan payment invoices',
|
||||
] : null,
|
||||
])->filter();
|
||||
|
||||
// Add instance settings only for self-hosted and root team
|
||||
if (! isCloud() && $team->id === 0) {
|
||||
$navigation->push(
|
||||
[
|
||||
'name' => 'Settings',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Instance settings and configuration',
|
||||
'link' => route('settings.index'),
|
||||
'search_text' => 'settings general configuration instance url domain name timezone public ip https redirect',
|
||||
],
|
||||
[
|
||||
'name' => 'Advanced Settings',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Registration, API access, MCP server, DNS validation and telemetry',
|
||||
'link' => route('settings.advanced'),
|
||||
'search_text' => 'settings advanced registration api access allowed ips mcp server dns validation custom dns domain connect confirmation telemetry sponsorship cdn',
|
||||
],
|
||||
[
|
||||
'name' => 'Update Settings',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure Coolify auto updates',
|
||||
'link' => route('settings.updates'),
|
||||
'search_text' => 'settings updates auto update upgrade version frequency',
|
||||
],
|
||||
[
|
||||
'name' => 'Instance Backup',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Back up the Coolify instance database',
|
||||
'link' => route('settings.backup'),
|
||||
'search_text' => 'settings instance backup coolify database restore',
|
||||
],
|
||||
[
|
||||
'name' => 'Instance Email',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure SMTP or Resend for system emails',
|
||||
'link' => route('settings.email'),
|
||||
'search_text' => 'settings instance email smtp resend transactional mail',
|
||||
],
|
||||
[
|
||||
'name' => 'Authentication',
|
||||
'type' => 'navigation',
|
||||
'description' => 'Configure OAuth login providers',
|
||||
'link' => route('settings.oauth'),
|
||||
'search_text' => 'settings authentication oauth sso login providers github google gitlab azure',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $navigation;
|
||||
}
|
||||
|
||||
private function search()
|
||||
|
||||
@@ -241,7 +241,7 @@ class ConnectApplication extends Component
|
||||
private function composeVolumeSnippet(Application $application): string
|
||||
{
|
||||
try {
|
||||
$services = data_get(Yaml::parse((string) $application->docker_compose_raw), 'services');
|
||||
$services = data_get(parseDockerComposeYaml((string) $application->docker_compose_raw), 'services');
|
||||
} catch (\Throwable) {
|
||||
$services = null;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class DockerCompose extends Component
|
||||
{
|
||||
@@ -38,7 +37,7 @@ class DockerCompose extends Component
|
||||
$this->validate([
|
||||
'dockerComposeRaw' => 'required',
|
||||
]);
|
||||
Yaml::parse($this->dockerComposeRaw);
|
||||
parseDockerComposeYaml($this->dockerComposeRaw);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
validateDockerComposeForInjection($this->dockerComposeRaw);
|
||||
|
||||
@@ -2189,10 +2189,7 @@ class Application extends BaseModel
|
||||
public function oldRawParser()
|
||||
{
|
||||
try {
|
||||
$yaml = Yaml::parse(
|
||||
$this->docker_compose_raw,
|
||||
maxAliasesForCollections: self::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES,
|
||||
);
|
||||
$yaml = parseDockerComposeYaml($this->docker_compose_raw);
|
||||
} catch (\Exception $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Stringable;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class LocalFileVolume extends BaseModel
|
||||
{
|
||||
@@ -691,7 +690,7 @@ class LocalFileVolume extends BaseModel
|
||||
}
|
||||
|
||||
try {
|
||||
$services = data_get(Yaml::parse($compose), 'services', []);
|
||||
$services = data_get(parseDockerComposeYaml($compose), 'services', []);
|
||||
foreach ($services as $service) {
|
||||
foreach (data_get($service, 'volumes', []) as $volume) {
|
||||
if (is_string($volume)) {
|
||||
@@ -777,7 +776,7 @@ class LocalFileVolume extends BaseModel
|
||||
}
|
||||
|
||||
// Parse the docker-compose content
|
||||
$compose = Yaml::parse($actualService->docker_compose_raw);
|
||||
$compose = parseDockerComposeYaml($actualService->docker_compose_raw);
|
||||
if (! isset($compose['services'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class LocalPersistentVolume extends BaseModel
|
||||
{
|
||||
@@ -191,7 +190,7 @@ class LocalPersistentVolume extends BaseModel
|
||||
return true;
|
||||
}
|
||||
|
||||
$compose = Yaml::parse($composeContent);
|
||||
$compose = parseDockerComposeYaml($composeContent);
|
||||
$services = data_get($compose, 'services', []);
|
||||
$topLevelVolumes = collect(data_get($compose, 'volumes') ?? []);
|
||||
|
||||
@@ -246,7 +245,7 @@ class LocalPersistentVolume extends BaseModel
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (data_get(Yaml::parse($composeContent), 'volumes') ?? [] as $key => $declaration) {
|
||||
foreach (data_get(parseDockerComposeYaml($composeContent), 'volumes') ?? [] as $key => $declaration) {
|
||||
$key = (string) $key;
|
||||
if (! isComposeExternalVolume($declaration)) {
|
||||
continue;
|
||||
@@ -293,7 +292,7 @@ class LocalPersistentVolume extends BaseModel
|
||||
}
|
||||
|
||||
$resourceUuid = $resource instanceof Application ? $resource->uuid : data_get($resource, 'service.uuid');
|
||||
foreach (data_get(Yaml::parse($composeContent), 'volumes') ?? [] as $key => $declaration) {
|
||||
foreach (data_get(parseDockerComposeYaml($composeContent), 'volumes') ?? [] as $key => $declaration) {
|
||||
if (! is_array($declaration) || isComposeExternalVolume($declaration)) {
|
||||
continue;
|
||||
}
|
||||
@@ -334,7 +333,7 @@ class LocalPersistentVolume extends BaseModel
|
||||
}
|
||||
|
||||
// Parse the docker-compose content
|
||||
$compose = Yaml::parse($actualService->docker_compose_raw);
|
||||
$compose = parseDockerComposeYaml($actualService->docker_compose_raw);
|
||||
if (! isset($compose['services'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Collection;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
#[OA\Schema(
|
||||
description: 'Service model',
|
||||
@@ -1629,7 +1628,7 @@ class Service extends BaseModel
|
||||
// Generate SERVICE_NAME_* environment variables from docker-compose services
|
||||
if ($this->docker_compose) {
|
||||
try {
|
||||
$dockerCompose = Yaml::parse($this->docker_compose);
|
||||
$dockerCompose = parseDockerComposeYaml($this->docker_compose);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
foreach ($services as $serviceName => $_) {
|
||||
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Traits\ReleasesManagedDnsRecords;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServiceApplication extends BaseModel
|
||||
{
|
||||
@@ -367,7 +366,7 @@ class ServiceApplication extends BaseModel
|
||||
return $this->service->getRequiredPort();
|
||||
}
|
||||
|
||||
$dockerCompose = Yaml::parse($dockerComposeRaw);
|
||||
$dockerCompose = parseDockerComposeYaml($dockerComposeRaw);
|
||||
$serviceConfig = $dockerCompose['services'][$this->name] ?? null;
|
||||
if (! $serviceConfig) {
|
||||
return $this->service->getRequiredPort();
|
||||
@@ -448,7 +447,7 @@ class ServiceApplication extends BaseModel
|
||||
*/
|
||||
private function getSavedLegacyRoutingPort(array $serviceConfig): ?int
|
||||
{
|
||||
$savedCompose = Yaml::parse($this->service->docker_compose ?? '');
|
||||
$savedCompose = parseDockerComposeYaml($this->service->docker_compose ?? '');
|
||||
$savedService = $savedCompose['services'][$this->name] ?? null;
|
||||
$image = $serviceConfig['image'] ?? null;
|
||||
if (! is_string($image) || $image === '' || ($savedService['image'] ?? null) !== $image) {
|
||||
|
||||
@@ -112,7 +112,7 @@ trait CalculatesExcludedStatus
|
||||
}
|
||||
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$dockerCompose = parseDockerComposeYaml($dockerComposeRaw);
|
||||
|
||||
// Validate structure
|
||||
if (! is_array($dockerCompose)) {
|
||||
|
||||
@@ -31,7 +31,7 @@ trait EnvironmentVariableProtection
|
||||
}
|
||||
|
||||
try {
|
||||
$dockerComposeData = Yaml::parse($dockerCompose);
|
||||
$dockerComposeData = parseDockerComposeYaml($dockerCompose);
|
||||
$dockerEnvVars = data_get($dockerComposeData, 'services.*.environment');
|
||||
|
||||
foreach ($dockerEnvVars as $serviceEnvs) {
|
||||
|
||||
@@ -95,6 +95,7 @@ trait ExecuteRemoteCommand
|
||||
$append = data_get($single_command, 'append', true);
|
||||
$command_hidden = data_get($single_command, 'command_hidden', false);
|
||||
$skip_command_log = data_get($single_command, 'skip_command_log', false);
|
||||
$input = data_get($single_command, 'input');
|
||||
$this->save = data_get($single_command, 'save');
|
||||
if ($this->server->isNonRoot()) {
|
||||
if (str($command)->startsWith('docker exec')) {
|
||||
@@ -119,7 +120,7 @@ trait ExecuteRemoteCommand
|
||||
|
||||
while ($attempt < $maxRetries && ! $commandExecuted) {
|
||||
try {
|
||||
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden, $skip_command_log);
|
||||
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden, $skip_command_log, $input);
|
||||
$commandExecuted = true;
|
||||
} catch (\RuntimeException|DeploymentException $e) {
|
||||
$lastError = $e;
|
||||
@@ -167,16 +168,20 @@ trait ExecuteRemoteCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual command with process handling
|
||||
* Execute the actual command with process handling. With $input, the content goes to the command over
|
||||
* SSH stdin, because Linux limits one exec argument to 128 KiB and generateSshCommand() puts the whole
|
||||
* script into one argument.
|
||||
*/
|
||||
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false)
|
||||
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false, ?string $input = null)
|
||||
{
|
||||
if ($command_hidden && ! $skip_command_log && isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true);
|
||||
}
|
||||
|
||||
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) {
|
||||
$remote_command = $input === null
|
||||
? SshMultiplexingHelper::generateSshCommand($this->server, $command)
|
||||
: SshMultiplexingHelper::generateSshStdinCommand($this->server, $command);
|
||||
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->input($input)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) {
|
||||
// Sanitize output to ensure valid UTF-8 encoding before JSON encoding
|
||||
$sanitized_output = sanitize_utf8_text($output);
|
||||
$log_output = str($sanitized_output)->trim();
|
||||
|
||||
@@ -908,7 +908,7 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra
|
||||
}
|
||||
|
||||
try {
|
||||
$services = data_get(Yaml::parse($compose), 'services', []);
|
||||
$services = data_get(parseDockerComposeYaml($compose), 'services', []);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
@@ -1813,7 +1813,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
|
||||
if (! $server) {
|
||||
throw new Exception('Server not found');
|
||||
}
|
||||
$yaml_compose = Yaml::parse($compose);
|
||||
$yaml_compose = parseDockerComposeYaml($compose);
|
||||
|
||||
// Remove Coolify's custom fields before Docker validation
|
||||
$yaml_compose = stripCoolifyCustomFields($yaml_compose);
|
||||
|
||||
@@ -15,8 +15,20 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Stringable;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Exception\ParseException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* Parses Docker Compose YAML with a higher collection alias limit than the Symfony default (128),
|
||||
* because large Compose files (for example Sentry self-hosted) reuse YAML anchors many times.
|
||||
*
|
||||
* @throws ParseException If the YAML is invalid or exceeds the alias limit
|
||||
*/
|
||||
function parseDockerComposeYaml(string $compose): mixed
|
||||
{
|
||||
return Yaml::parse($compose, maxAliasesForCollections: Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Docker Compose YAML string for command injection vulnerabilities.
|
||||
* This should be called BEFORE saving to database to prevent malicious data from being stored.
|
||||
@@ -28,7 +40,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
function validateDockerComposeForInjection(string $composeYaml): void
|
||||
{
|
||||
try {
|
||||
$parsed = Yaml::parse($composeYaml);
|
||||
$parsed = parseDockerComposeYaml($composeYaml);
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
@@ -515,7 +527,7 @@ function composeExternalVolumeDockerNames(?string $compose): array
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$volumes = data_get(Yaml::parse($compose), 'volumes');
|
||||
$volumes = data_get(parseDockerComposeYaml($compose), 'volumes');
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
@@ -542,7 +554,7 @@ function composeExternalVolumeMounts(?string $compose): array
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$yaml = Yaml::parse($compose);
|
||||
$yaml = parseDockerComposeYaml($compose);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
@@ -985,7 +997,7 @@ function removeComposeVolumeFieldsPreservingComments(string $source, array $clea
|
||||
|
||||
$candidate = implode('', $result);
|
||||
try {
|
||||
if (Yaml::parse($candidate) === $cleanedYaml) {
|
||||
if (parseDockerComposeYaml($candidate) === $cleanedYaml) {
|
||||
return $candidate;
|
||||
}
|
||||
} catch (Exception) {
|
||||
@@ -1010,7 +1022,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
$isPullRequest = $pullRequestId == 0 ? false : true;
|
||||
$server = data_get($resource, 'destination.server');
|
||||
try {
|
||||
$yaml = Yaml::parse($compose);
|
||||
$yaml = parseDockerComposeYaml($compose);
|
||||
} catch (Exception) {
|
||||
return collect([]);
|
||||
}
|
||||
@@ -2150,7 +2162,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
// This keeps the original user input clean while preventing content reapplication
|
||||
// Parse the original compose again to create a clean version without Coolify additions
|
||||
try {
|
||||
$originalYaml = Yaml::parse($originalCompose);
|
||||
$originalYaml = parseDockerComposeYaml($originalCompose);
|
||||
$originalYamlBeforeCleanup = $originalYaml;
|
||||
// Remove content, isDirectory, and is_directory from all volume definitions
|
||||
if (isset($originalYaml['services'])) {
|
||||
@@ -2197,7 +2209,7 @@ function serviceParser(Service $resource): Collection
|
||||
$server = data_get($resource, 'server');
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($compose);
|
||||
$yaml = parseDockerComposeYaml($compose);
|
||||
} catch (Exception) {
|
||||
return collect([]);
|
||||
}
|
||||
@@ -3383,7 +3395,7 @@ function serviceParser(Service $resource): Collection
|
||||
// This keeps the original user input clean while preventing content reapplication
|
||||
// Parse the original compose again to create a clean version without Coolify additions
|
||||
try {
|
||||
$originalYaml = Yaml::parse($originalCompose);
|
||||
$originalYaml = parseDockerComposeYaml($originalCompose);
|
||||
$originalYamlBeforeCleanup = $originalYaml;
|
||||
// Remove content, isDirectory, and is_directory from all volume definitions
|
||||
if (isset($originalYaml['services'])) {
|
||||
|
||||
@@ -269,7 +269,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
||||
if (! $dockerComposeRaw) {
|
||||
throw new Exception('No compose file found or not a valid YAML file.');
|
||||
}
|
||||
$dockerCompose = Yaml::parse($dockerComposeRaw);
|
||||
$dockerCompose = parseDockerComposeYaml($dockerComposeRaw);
|
||||
|
||||
// Switch Image
|
||||
$updatedImage = data_get_str($resource, 'image');
|
||||
|
||||
@@ -1736,7 +1736,7 @@ function getTopLevelNetworks(Service|Application $resource): Collection
|
||||
if ($resource->getMorphClass() === Service::class) {
|
||||
if ($resource->docker_compose_raw) {
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
|
||||
} catch (Exception $e) {
|
||||
// If the docker-compose.yml file is not valid, we will return the network name as the key
|
||||
$topLevelNetworks = collect([
|
||||
@@ -1802,7 +1802,7 @@ function getTopLevelNetworks(Service|Application $resource): Collection
|
||||
}
|
||||
} elseif ($resource->getMorphClass() === Application::class) {
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
|
||||
} catch (Exception $e) {
|
||||
// If the docker-compose.yml file is not valid, we will return the network name as the key
|
||||
$topLevelNetworks = collect([
|
||||
@@ -2742,7 +2742,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
$envComments = extractYamlEnvironmentComments($resource->docker_compose_raw);
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
|
||||
} catch (Exception $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
@@ -3534,7 +3534,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
}
|
||||
} elseif ($resource->getMorphClass() === Application::class) {
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
|
||||
} catch (Exception) {
|
||||
return;
|
||||
}
|
||||
@@ -4406,8 +4406,9 @@ function convertToKeyValueCollection($environment)
|
||||
$key = $parts[0];
|
||||
$realValue = $parts[1] ?? '';
|
||||
$changedEnvironment->put($key, $realValue);
|
||||
} else {
|
||||
$changedEnvironment->put($key, $value);
|
||||
} elseif (is_string($value) && $value !== '') {
|
||||
// A bare name (for example a list-style build arg) is the same as `NAME:` without a value.
|
||||
$changedEnvironment->put($value, $changedEnvironment->get($value));
|
||||
}
|
||||
} else {
|
||||
$changedEnvironment->put($key, $value);
|
||||
@@ -4450,6 +4451,49 @@ function wireNavigate(): string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a grouped settings sidebar into the items that the command palette
|
||||
* shows for the current page: every page, its child pages, and its in-page sections.
|
||||
*
|
||||
* @param iterable<string, iterable<array{label: string, route: string, navigate?: bool, visible?: bool, children?: array<int, array{label: string, route: string, navigate?: bool, visible?: bool}>}>> $groupedItems
|
||||
* @param array<string, string> $routeParameters
|
||||
* @param array<string, array<int, array{id: string, label: string}>> $pageSections In-page sections keyed by page route
|
||||
* @return array<int, array{label: string, breadcrumb: string, search_text: string, href: string, navigate: bool}>
|
||||
*/
|
||||
function settingsSearchItems(iterable $groupedItems, array $routeParameters, array $pageSections = []): array
|
||||
{
|
||||
$spaNavigation = wireNavigate() !== '';
|
||||
$items = [];
|
||||
$add = function (string $label, string $breadcrumb, string $href, bool $navigate) use (&$items, $spaNavigation): void {
|
||||
$items[] = [
|
||||
'label' => $label,
|
||||
'breadcrumb' => $breadcrumb,
|
||||
'search_text' => $label.' '.$breadcrumb,
|
||||
'href' => $href,
|
||||
'navigate' => $navigate && $spaNavigation,
|
||||
];
|
||||
};
|
||||
|
||||
foreach ($groupedItems as $groupLabel => $groupItems) {
|
||||
foreach ($groupItems as $item) {
|
||||
$href = route($item['route'], $routeParameters);
|
||||
$add($item['label'], $groupLabel, $href, $item['navigate'] ?? true);
|
||||
|
||||
foreach ($item['children'] ?? [] as $child) {
|
||||
if ($child['visible'] ?? true) {
|
||||
$add($child['label'], $groupLabel.' · '.$item['label'], route($child['route'], $routeParameters), $child['navigate'] ?? true);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pageSections[$item['route']] ?? [] as $section) {
|
||||
$add($section['label'], $groupLabel.' · '.$item['label'], $href.'#'.$section['id'], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to a named route with SPA navigation support.
|
||||
* Automatically uses wire:navigate when is_wire_navigate_enabled is true.
|
||||
@@ -5052,7 +5096,7 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect
|
||||
}
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($dockerComposeRaw);
|
||||
$yaml = parseDockerComposeYaml($dockerComposeRaw);
|
||||
} catch (Exception $e) {
|
||||
// Malformed YAML - return empty collection
|
||||
return collect([]);
|
||||
|
||||
+22
-25
@@ -4509,7 +4509,7 @@ html[data-theme="custom"] .runtime-log-columns {
|
||||
.command-palette {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
max-width: 42rem;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
@@ -4566,47 +4566,44 @@ html[data-theme="custom"] .runtime-log-columns {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.command-palette-shortcuts {
|
||||
display: flex;
|
||||
/* Search scope switch (Global / This page), shown on resource settings pages */
|
||||
.command-palette-scope {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
gap: 0.125rem;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--coollabs-hairline);
|
||||
}
|
||||
|
||||
.command-palette-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
.command-palette-scope-option {
|
||||
height: 1.375rem;
|
||||
padding: 0 0.375rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid transparent;
|
||||
background: var(--coollabs-recessed);
|
||||
color: var(--coollabs-subtle);
|
||||
font-size: 0.6875rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dark .command-palette-kbd {
|
||||
border-color: rgb(255 255 255 / 0.05);
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
}
|
||||
|
||||
button.command-palette-kbd {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
button.command-palette-kbd:hover {
|
||||
.command-palette-scope-option:hover {
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.command-palette-scope-option[aria-pressed='true'] {
|
||||
color: #171717;
|
||||
background: var(--coollabs-fill);
|
||||
}
|
||||
|
||||
.dark button.command-palette-kbd:hover {
|
||||
.dark .command-palette-scope-option:hover,
|
||||
.dark .command-palette-scope-option[aria-pressed='true'] {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.dark .command-palette-scope-option[aria-pressed='true'] {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
|
||||
+44
-3
@@ -34,10 +34,10 @@ document.addEventListener('alpine:init', initializeSettingsSidebarAccordionCompo
|
||||
* after the scroll has settled. Starting the flash immediately makes long
|
||||
* jumps (top → bottom) finish scrolling after the animation has already ended.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string|HTMLElement} target Section id or element
|
||||
*/
|
||||
window.scrollToSettingsSection = function scrollToSettingsSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
window.scrollToSettingsSection = function scrollToSettingsSection(target) {
|
||||
const el = typeof target === 'string' ? document.getElementById(target) : target;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
@@ -136,6 +136,47 @@ window.scrollToSettingsSection = function scrollToSettingsSection(id) {
|
||||
rafId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
/**
|
||||
* Items for the "This page" scope of the command palette: the pages of the
|
||||
* settings sidebar (rendered by the server), then the visible sections of the
|
||||
* current page. Sections that the sidebar already lists are skipped.
|
||||
*
|
||||
* @returns {Array<{label: string, breadcrumb: string, search_text: string, href: string, navigate?: boolean, element?: HTMLElement}>}
|
||||
*/
|
||||
window.currentPageSearchItems = function currentPageSearchItems() {
|
||||
let items = [];
|
||||
try {
|
||||
items = JSON.parse(document.querySelector('[data-settings-search-items]')?.dataset.settingsSearchItems || '[]');
|
||||
} catch (e) {
|
||||
items = [];
|
||||
}
|
||||
|
||||
const toPath = (href) => {
|
||||
const url = new URL(href, window.location.href);
|
||||
return url.pathname + url.hash;
|
||||
};
|
||||
const sidebarPaths = new Set(items.map((item) => toPath(item.href)));
|
||||
const headingSelector = ':scope > header :is(h1, h2, h3), :scope > .application-settings-section-header :is(h1, h2, h3)';
|
||||
|
||||
document.querySelectorAll('.application-settings-section').forEach((section, index) => {
|
||||
if (section.closest('[role=dialog], .command-palette') || section.getClientRects().length === 0) {
|
||||
return;
|
||||
}
|
||||
if (section.id && sidebarPaths.has(window.location.pathname + '#' + section.id)) {
|
||||
return;
|
||||
}
|
||||
const label = (section.dataset.settingsSectionTitle ?? section.querySelector(headingSelector)?.textContent ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
items.push({ label, breadcrumb: 'Section', search_text: label + ' section', href: 'section-' + index, element: section });
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// When a settings sub-section link navigates across pages (href="route#section-id"),
|
||||
// scroll to that section once the destination page has rendered.
|
||||
function scrollToHashSettingsSection() {
|
||||
|
||||
@@ -9,21 +9,6 @@ export function initializeSettingsSidebarAccordionComponent() {
|
||||
activeGroup: config.activeGroup || '',
|
||||
storageKey: config.storageKey || 'coolify.settings-sidebar',
|
||||
groups: {},
|
||||
// Optional client-side filter (sidebars that render a search box).
|
||||
search: '',
|
||||
labels: Array.isArray(config.labels) ? config.labels : [],
|
||||
get searching() {
|
||||
return this.search.trim() !== '';
|
||||
},
|
||||
matches(label) {
|
||||
if (!this.searching) {
|
||||
return true;
|
||||
}
|
||||
return String(label).toLowerCase().includes(this.search.trim().toLowerCase());
|
||||
},
|
||||
get hasResults() {
|
||||
return !this.searching || this.labels.some((label) => this.matches(label));
|
||||
},
|
||||
init() {
|
||||
let stored = {};
|
||||
try {
|
||||
|
||||
@@ -243,81 +243,23 @@
|
||||
],
|
||||
];
|
||||
|
||||
// Flat, searchable index: every page plus its in-page sub-sections. Each
|
||||
// entry carries a breadcrumb (its category, and parent page for a
|
||||
// sub-section) and combined text so the query matches sub-pages too.
|
||||
$searchIndex = [];
|
||||
foreach ($groupedMenuItems as $groupLabel => $groupItems) {
|
||||
foreach ($groupItems as $item) {
|
||||
$searchIndex[] = [
|
||||
'label' => $item['label'],
|
||||
'breadcrumb' => $groupLabel,
|
||||
'searchText' => $item['label'].' '.$groupLabel,
|
||||
'href' => route($item['route'], $applicationRouteParameters),
|
||||
'icon' => $menuIcons[$item['label']] ?? 'settings',
|
||||
'navigate' => $item['navigate'] ?? true,
|
||||
];
|
||||
foreach ($pageSections[$item['route']] ?? [] as $section) {
|
||||
$searchIndex[] = [
|
||||
'label' => $section['label'],
|
||||
'breadcrumb' => $groupLabel.' · '.$item['label'],
|
||||
'searchText' => $section['label'].' '.$item['label'].' '.$groupLabel,
|
||||
'href' => route($item['route'], $applicationRouteParameters).'#'.$section['id'],
|
||||
'icon' => $menuIcons[$item['label']] ?? 'settings',
|
||||
'navigate' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$searchTexts = array_column($searchIndex, 'searchText');
|
||||
// Pages and in-page sections that the command palette (⌘K) can search on this page
|
||||
$settingsSearchItems = settingsSearchItems($groupedMenuItems, $applicationRouteParameters, $pageSections);
|
||||
@endphp
|
||||
|
||||
<aside @class([
|
||||
'application-settings-navigation min-w-0 xl:self-start',
|
||||
'is-flush' => $flush,
|
||||
])>
|
||||
]) data-settings-search-items="{{ json_encode($settingsSearchItems) }}">
|
||||
<nav aria-label="Configuration sections"
|
||||
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application', labels: @js($searchTexts) })"
|
||||
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application' })"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
{{-- A quiet inline filter, deliberately lighter than the global ⌘K
|
||||
search so the two don't read as duplicate search bars. --}}
|
||||
<div class="relative col-span-full mb-1.5 xl:mb-2">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input x-model.debounce.100ms="search" type="search" placeholder="Filter settings"
|
||||
aria-label="Filter settings"
|
||||
class="h-7 w-full rounded-md border-0 bg-black/[0.035] py-0 pr-7 pl-7 text-[12px] text-nav-text shadow-none outline-none ring-0 transition-colors placeholder:text-neutral-400 focus:bg-black/[0.05] focus-visible:ring-1 focus-visible:ring-accent/40 dark:bg-white/[0.04] dark:text-fg dark:placeholder:text-fg-faint dark:focus:bg-white/[0.06]">
|
||||
<button x-cloak x-show="searching" type="button" @click="search = ''" aria-label="Clear filter"
|
||||
class="absolute top-1/2 right-1 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:text-black dark:text-fg-faint dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p x-cloak x-show="searching && !hasResults"
|
||||
class="col-span-full px-2.5 py-2 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
No settings match “<span x-text="search"></span>”.
|
||||
</p>
|
||||
|
||||
{{-- Flat search results (pages + sub-sections), each with its category/parent. --}}
|
||||
<div x-cloak x-show="searching" class="col-span-full flex flex-col gap-0.5">
|
||||
@foreach ($searchIndex as $entry)
|
||||
<a href="{{ $entry['href'] }}" @if ($entry['navigate']) {{ wireNavigate() }} @endif
|
||||
x-show="matches(@js($entry['searchText']))"
|
||||
class="group flex flex-col gap-0.5 rounded-md px-2.5 py-1.5 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.05]">
|
||||
<span class="flex min-w-0 items-center gap-2.5">
|
||||
<x-reicon :name="$entry['icon']" class="size-[18px] shrink-0 text-nav-text opacity-90" />
|
||||
<span class="truncate text-[13px] font-medium text-nav-text">{{ $entry['label'] }}</span>
|
||||
</span>
|
||||
<span class="truncate pl-[28px] text-[11px] text-neutral-400 dark:text-fg-faint">{{ $entry['breadcrumb'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
|
||||
@unless ($loop->first)
|
||||
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
|
||||
x-show="!searching" aria-hidden="true"></div>
|
||||
aria-hidden="true"></div>
|
||||
@endunless
|
||||
<button type="button" class="nav-section-toggle hidden xl:flex" x-show="!searching"
|
||||
<button type="button" class="nav-section-toggle hidden xl:flex"
|
||||
@click="toggle(@js($groupLabel))" :aria-expanded="isOpen(@js($groupLabel))">
|
||||
<span>{{ $groupLabel }}</span>
|
||||
<svg class="size-3 shrink-0 opacity-60 transition-transform"
|
||||
@@ -329,8 +271,7 @@
|
||||
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
|
||||
@foreach ($groupItems as $menuItem)
|
||||
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
|
||||
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}"
|
||||
x-show="!searching">
|
||||
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}">
|
||||
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'menu-item',
|
||||
@@ -351,7 +292,7 @@
|
||||
{{-- Sub-sections belong to the current page only; collapse them for
|
||||
every other item so the sidebar stays short. --}}
|
||||
@if ($menuItem['active'] && filled($sections))
|
||||
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex" x-show="!searching"
|
||||
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
|
||||
x-data="{ activeSection: '' }">
|
||||
@foreach ($sections as $section)
|
||||
<button type="button" class="menu-subitem"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
'flush' => false,
|
||||
])
|
||||
|
||||
<section {{ $attributes->merge(['class' => 'application-settings-section']) }}>
|
||||
<section {{ $attributes->merge(['class' => 'application-settings-section', 'data-settings-section-title' => $title]) }}>
|
||||
<header>
|
||||
<div class="min-w-0 py-0.5">
|
||||
@if (filled($description ?? $helper))
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
};
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems(['Backup' => collect($items)->map(fn (array $item): array => [...$item, 'route' => $routes[$item['key']]])], $parameters)) }}">
|
||||
<nav aria-label="Backup settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Backup</div>
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
: [];
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $databaseRouteParameters, ['project.database.configuration' => $pageSections])) }}">
|
||||
<nav aria-label="Database settings"
|
||||
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.database' })"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
|
||||
@@ -55,12 +55,18 @@
|
||||
{{-- Search is only useful when workspace resources are available --}}
|
||||
@if (isSubscribed() || ! isCloud())
|
||||
<div class="px-1 pb-3" :class="collapsed && 'lg:px-0'">
|
||||
{{-- On pages with settings or sections the palette also searches the current page --}}
|
||||
<button @click="$dispatch('open-global-search')" type="button"
|
||||
:title="'Search (Press / or ' + modKeyLabel + 'K)'"
|
||||
x-data="{ hasPageSearch: false }"
|
||||
x-init="$nextTick(() => hasPageSearch = (window.currentPageSearchItems?.() ?? []).length > 0)"
|
||||
: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">
|
||||
<span class="flex items-center gap-2.5 min-w-0">
|
||||
<x-reicon name="search" class="menu-item-icon" />
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Search</span>
|
||||
<span class="menu-item-label truncate" :class="collapsed && 'lg:hidden'">Search<span
|
||||
x-show="hasPageSearch" x-cloak class="text-[11px] opacity-70"> page & global</span></span>
|
||||
</span>
|
||||
<kbd class="px-1.5 py-0.5 text-[11px] font-medium text-fg-faint bg-neutral-200 dark:bg-white/[0.06] rounded-md border border-transparent dark:border-white/5"
|
||||
:class="collapsed && 'lg:hidden'" x-text="modKeyLabel + 'K'"></kbd>
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
<p class="settings-mobile-description">Configure how your team receives deployment and system alerts.</p>
|
||||
</header>
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems(['Notifications' => $notificationMenuItems], [])) }}">
|
||||
<nav aria-label="Notification settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Notifications</div>
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
<p class="settings-mobile-description">Manage SSH keys, cloud credentials, and API access tokens.</p>
|
||||
</header>
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems(['Keys & Tokens' => $securityMenuItems], [])) }}">
|
||||
<nav aria-label="Keys and tokens"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Keys & Tokens</div>
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedServerMenuItems, $serverRouteParameters)) }}"
|
||||
x-data="{
|
||||
proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),
|
||||
traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo()),
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
|
||||
<nav aria-label="Service settings"
|
||||
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
<p class="settings-mobile-description">Configure global settings for this Coolify instance.</p>
|
||||
</header>
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems($settingsMenuSections, [])) }}">
|
||||
<nav aria-label="Instance settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($settingsMenuSections as $section => $menuItems)
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
</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">
|
||||
<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)
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
<p class="settings-mobile-description">Manage your team, members, and access settings.</p>
|
||||
</header>
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems(['Team' => $teamMenuItems], [])) }}">
|
||||
<nav aria-label="Team settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Team</div>
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
])->filter();
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems(['Destination' => $destinationMenuItems], $destinationRouteParameters)) }}">
|
||||
<nav aria-label="Destination settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Settings</div>
|
||||
|
||||
@@ -11,12 +11,10 @@
|
||||
searchQuery: '',
|
||||
creatableItems: [],
|
||||
isCreateMode: false,
|
||||
// macOS/iOS use ⌘; Windows/Linux use Ctrl+
|
||||
modKeyLabel: (() => {
|
||||
const platform = navigator.userAgentData?.platform || navigator.platform || '';
|
||||
const ua = navigator.userAgent || '';
|
||||
return /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X|Macintosh/i.test(ua) ? '⌘' : 'Ctrl+';
|
||||
})(),
|
||||
// Items of the open page: its sidebar pages and its sections. 'global' shows them
|
||||
// above the global results; 'page' shows only them. Tab switches the scope.
|
||||
pageItems: [],
|
||||
scope: 'global',
|
||||
serverTimingHudEnabled: localStorage.getItem('coolify.serverTimingHud.enabled') !== '0',
|
||||
developerCommandsEnabled: @js(app()->environment('local')),
|
||||
|
||||
@@ -33,9 +31,60 @@
|
||||
this.closeModal();
|
||||
},
|
||||
|
||||
get pageResults() {
|
||||
const query = this.searchQuery.toLowerCase().trim();
|
||||
if (this.scope !== 'page' && !query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = this.pageItems.filter(item => item.search_text.toLowerCase().includes(query));
|
||||
return this.scope === 'page' ? results : results.slice(0, 5);
|
||||
},
|
||||
|
||||
setScope(scope) {
|
||||
this.scope = scope;
|
||||
this.selectedIndex = -1;
|
||||
this.$refs.searchInput?.focus();
|
||||
},
|
||||
|
||||
toggleScope() {
|
||||
this.setScope(this.scope === 'page' ? 'global' : 'page');
|
||||
},
|
||||
|
||||
// Mobile browsers (iOS Safari) open the keyboard only when focus happens inside the
|
||||
// tap handler. The palette is still hidden here, so focus a temporary input now and
|
||||
// move the focus to the search input when the palette shows. The keyboard stays open.
|
||||
holdKeyboardFocus() {
|
||||
const holder = document.createElement('input');
|
||||
holder.setAttribute('aria-hidden', 'true');
|
||||
holder.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;';
|
||||
document.body.appendChild(holder);
|
||||
holder.focus();
|
||||
setTimeout(() => holder.remove(), 1000);
|
||||
},
|
||||
|
||||
openPageItem(item) {
|
||||
this.closeModal();
|
||||
if (item.element) {
|
||||
if (item.element.id) history.replaceState(null, '', '#' + item.element.id);
|
||||
window.scrollToSettingsSection?.(item.element);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(item.href, window.location.href);
|
||||
if (url.pathname === window.location.pathname && url.hash) {
|
||||
history.replaceState(null, '', url.hash);
|
||||
window.scrollToSettingsSection?.(url.hash.slice(1));
|
||||
} else if (item.navigate && window.Livewire?.navigate) {
|
||||
window.Livewire.navigate(url.href);
|
||||
} else {
|
||||
window.location.assign(url.href);
|
||||
}
|
||||
},
|
||||
|
||||
// Client-side search function
|
||||
get searchResults() {
|
||||
if (!this.searchQuery || this.searchQuery.length < 1) {
|
||||
if (this.scope === 'page' || !this.searchQuery || this.searchQuery.length < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -55,7 +104,7 @@
|
||||
},
|
||||
|
||||
get filteredCreatableItems() {
|
||||
if (!this.searchQuery || this.searchQuery.length < 1) {
|
||||
if (this.scope === 'page' || !this.searchQuery || this.searchQuery.length < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -102,11 +151,14 @@
|
||||
}
|
||||
clearTimeout(this.closeResetTimer);
|
||||
clearTimeout(this.spinnerTimer);
|
||||
this.holdKeyboardFocus();
|
||||
this.modalOpen = true;
|
||||
this.selectedIndex = -1;
|
||||
this.isLoadingInitialData = true;
|
||||
this.showLoadingSpinner = false;
|
||||
this.searchQuery = '';
|
||||
this.pageItems = window.currentPageSearchItems?.() ?? [];
|
||||
this.scope = 'global';
|
||||
// Only show the spinner when loading takes longer than 150ms, so fast (cached) loads do not flash the icon
|
||||
this.spinnerTimer = setTimeout(() => {
|
||||
if (this.isLoadingInitialData) this.showLoadingSpinner = true;
|
||||
@@ -207,7 +259,7 @@
|
||||
'new postgresql', 'new postgres', 'new mysql', 'new mariadb',
|
||||
'new redis', 'new keydb', 'new dragonfly', 'new mongodb', 'new mongo', 'new clickhouse', 'new sqlite'
|
||||
];
|
||||
if (exactMatchCommands.includes(trimmed)) {
|
||||
if (this.scope === 'global' && exactMatchCommands.includes(trimmed)) {
|
||||
const matchingItem = this.creatableItems.find(item => {
|
||||
const itemSearchText = `new ${item.name}`.toLowerCase();
|
||||
const itemType = `new ${item.type}`.toLowerCase();
|
||||
@@ -271,6 +323,14 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
// Capture phase, so the focus trap of the open palette does not move focus first
|
||||
const tabKeyHandler = (e) => {
|
||||
if (e.key !== 'Tab' || !this.modalOpen || this.pageItems.length === 0) return;
|
||||
if (document.activeElement !== this.$refs.searchInput) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
this.toggleScope();
|
||||
};
|
||||
const arrowKeyHandler = (e) => {
|
||||
if (!this.modalOpen) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
@@ -288,6 +348,7 @@
|
||||
document.addEventListener('keydown', cmdKHandler);
|
||||
document.addEventListener('keydown', escapeKeyHandler);
|
||||
document.addEventListener('keydown', arrowKeyHandler);
|
||||
document.addEventListener('keydown', tabKeyHandler, true);
|
||||
|
||||
// Cleanup on component destroy
|
||||
this.$el.addEventListener('alpine:destroy', () => {
|
||||
@@ -296,6 +357,7 @@
|
||||
document.removeEventListener('keydown', cmdKHandler);
|
||||
document.removeEventListener('keydown', escapeKeyHandler);
|
||||
document.removeEventListener('keydown', arrowKeyHandler);
|
||||
document.removeEventListener('keydown', tabKeyHandler, true);
|
||||
});
|
||||
|
||||
// Watch for auto-open resource (only if $wire is available)
|
||||
@@ -358,20 +420,21 @@
|
||||
</svg>
|
||||
</span>
|
||||
<input type="text" x-model="searchQuery"
|
||||
placeholder="Search resources, paths, everything (type new for create)..." x-ref="searchInput"
|
||||
:placeholder="scope === 'page' ? 'Search this page…' : 'Search resources, paths, everything (type new for create)...'"
|
||||
x-ref="searchInput"
|
||||
x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })"
|
||||
class="command-palette-input" autocomplete="off" spellcheck="false" />
|
||||
<div class="command-palette-shortcuts">
|
||||
<span class="command-palette-kbd">/</span>
|
||||
<span class="command-palette-kbd" x-text="modKeyLabel + 'K'"></span>
|
||||
<button type="button" @click="closeModal()" class="command-palette-kbd" title="Close">
|
||||
ESC
|
||||
</button>
|
||||
<div x-show="pageItems.length > 0" x-cloak class="command-palette-scope" role="group"
|
||||
aria-label="Search scope" title="Press Tab to switch between global search and this page">
|
||||
<button type="button" @click="setScope('global')" :aria-pressed="scope === 'global'"
|
||||
class="command-palette-scope-option">Global</button>
|
||||
<button type="button" @click="setScope('page')" :aria-pressed="scope === 'page'"
|
||||
class="command-palette-scope-option">This page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div x-show="searchQuery.length >= 1" x-cloak class="command-palette-body relative">
|
||||
<div x-show="searchQuery.length >= 1 || scope === 'page'" x-cloak class="command-palette-body relative">
|
||||
@if (app()->environment('local'))
|
||||
<div x-show="showServerTimingCommand && !$wire.isSelectingResource"
|
||||
class="command-palette-section">
|
||||
@@ -602,9 +665,31 @@
|
||||
@endif
|
||||
|
||||
<div wire:ignore>
|
||||
<template x-if="pageResults.length > 0 && !$wire.isSelectingResource">
|
||||
<div class="command-palette-section">
|
||||
<div class="command-palette-group-label">This page</div>
|
||||
<template x-for="item in pageResults" :key="item.href">
|
||||
<a :href="item.element ? (item.element.id ? '#' + item.element.id : '#') : item.href" @click.prevent="openPageItem(item)"
|
||||
class="search-result-item command-palette-item">
|
||||
<div class="command-palette-item-main">
|
||||
<div class="command-palette-item-title">
|
||||
<span class="command-palette-item-name" x-text="item.label"></span>
|
||||
</div>
|
||||
<div class="command-palette-item-meta" x-text="item.breadcrumb"></div>
|
||||
</div>
|
||||
<svg class="command-palette-item-chevron" viewBox="0 0 24 24" fill="none"
|
||||
aria-hidden="true">
|
||||
<path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="searchQuery.length >= 1 && searchResults.length > 0 && !$wire.isSelectingResource">
|
||||
<div class="command-palette-section">
|
||||
<template x-if="filteredCreatableItems.length > 0">
|
||||
<template x-if="filteredCreatableItems.length > 0 || pageResults.length > 0">
|
||||
<div class="command-palette-group-label">Existing resources</div>
|
||||
</template>
|
||||
<template x-for="(result, index) in searchResults" :key="index">
|
||||
@@ -704,7 +789,7 @@
|
||||
</template>
|
||||
|
||||
<template
|
||||
x-if="searchQuery.length >= 2 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !showServerTimingCommand && !$wire.isSelectingResource && !$wire.autoOpenResource && !isLoadingInitialData">
|
||||
x-if="scope === 'global' && searchQuery.length >= 2 && pageResults.length === 0 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !showServerTimingCommand && !$wire.isSelectingResource && !$wire.autoOpenResource && !isLoadingInitialData">
|
||||
<div class="command-palette-empty">
|
||||
<p class="command-palette-empty-title">No results found</p>
|
||||
<p class="command-palette-empty-desc">
|
||||
@@ -712,6 +797,15 @@
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="scope === 'page' && pageResults.length === 0">
|
||||
<div class="command-palette-empty">
|
||||
<p class="command-palette-empty-title">Nothing on this page</p>
|
||||
<p class="command-palette-empty-desc">
|
||||
Press <span class="font-medium">Tab</span> to search everything.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
|
||||
<nav aria-label="Service settings"
|
||||
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
@if ($showSettingsSidebar)
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems(['GitHub App' => $settingsMenuItems], $githubAppRouteParameters)) }}">
|
||||
<nav aria-label="GitHub App settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Settings</div>
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
@if ($showSettingsSidebar)
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
data-settings-search-items="{{ json_encode(settingsSearchItems(['S3 storage' => $settingsMenuItems], $storageRouteParameters)) }}">
|
||||
<nav aria-label="S3 storage settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Settings</div>
|
||||
|
||||
@@ -373,7 +373,7 @@ test('the Compose deployment validates the repository file before any command us
|
||||
->and($body)->not->toContain('$this->application->loadComposeFile(')
|
||||
->and($loadPosition)->toBeLessThan(strpos($body, '$this->application->oldRawParser()'))
|
||||
->and($loadPosition)->toBeLessThan(strpos($body, '$this->parseComposeFileForDeployment()'))
|
||||
->and($loadPosition)->toBeLessThan(strpos($body, 'base64 -d'))
|
||||
->and($loadPosition)->toBeLessThan(strpos($body, "'input' =>"))
|
||||
->and($body)->toContain('"stat -c \'%F\' ".escapeshellarg($realPathInGit)');
|
||||
});
|
||||
|
||||
|
||||
@@ -411,7 +411,7 @@ it('hides the Nixpacks plan write command that contains build-time values', func
|
||||
'not static' => [false],
|
||||
]);
|
||||
|
||||
it('hides the compose file write command because compose files can contain secrets', function () {
|
||||
it('streams the compose file over stdin so its content stays out of commands, errors, and logs', function () {
|
||||
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob();
|
||||
$server = readSensitiveFailureJobProperty($job, $reflection, 'server');
|
||||
setSensitiveFailureJobProperties($job, $reflection, [
|
||||
@@ -419,12 +419,10 @@ it('hides the compose file write command because compose files can contain secre
|
||||
'production_image_name' => 'example/app:latest',
|
||||
]);
|
||||
|
||||
$payloads = [];
|
||||
Process::fake(function (PendingProcess $process) use (&$payloads) {
|
||||
preg_match_all('~[A-Za-z0-9+/]{16,}={0,2}~', $process->command, $matches);
|
||||
$composePayloads = array_filter($matches[0], fn (string $candidate): bool => str_contains((string) base64_decode($candidate, true), 'services:'));
|
||||
if ($composePayloads !== []) {
|
||||
$payloads = array_merge($payloads, $composePayloads);
|
||||
$composeWrite = null;
|
||||
Process::fake(function (PendingProcess $process) use (&$composeWrite) {
|
||||
if (str_contains((string) $process->input, 'services:')) {
|
||||
$composeWrite = $process;
|
||||
|
||||
return Process::result(errorOutput: 'tee: docker-compose.yaml: No space left on device', exitCode: 1);
|
||||
}
|
||||
@@ -434,15 +432,14 @@ it('hides the compose file write command because compose files can contain secre
|
||||
|
||||
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'generate_compose_file'));
|
||||
|
||||
expect($payloads)->not->toBeEmpty();
|
||||
expect($exception->getMessage())
|
||||
->toContain('[command hidden because it contains sensitive data]')
|
||||
->toContain('No space left on device');
|
||||
$storedLogs = (string) $queue->fresh()->logs;
|
||||
foreach ($payloads as $payload) {
|
||||
expect($exception->getMessage())->not->toContain($payload);
|
||||
expect($storedLogs)->not->toContain($payload);
|
||||
}
|
||||
$compose = (string) readSensitiveFailureJobProperty($job, $reflection, 'docker_compose');
|
||||
expect($composeWrite)->not->toBeNull()
|
||||
->and($composeWrite->input)->toBe($compose)
|
||||
->and($composeWrite->command)->toEndWith(escapeshellarg('docker exec -i sensitive-failure-deployment tee /artifacts/sensitive-failure/docker-compose.yaml > /dev/null'))
|
||||
->and($composeWrite->command)->not->toContain('services:')
|
||||
->and($exception->getMessage())->toContain('No space left on device')
|
||||
->and($exception->getMessage())->not->toContain('services:')
|
||||
->and((string) $queue->fresh()->logs)->not->toContain('services:');
|
||||
});
|
||||
|
||||
function readSensitiveFailureJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\GlobalSearch;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
function globalSearchNavigationLinks(Team $team, string $role = 'owner'): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->teams()->attach($team, ['role' => $role]);
|
||||
test()->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return collect(Livewire::test(GlobalSearch::class)->call('openSearchModal')->get('allSearchableItems'))
|
||||
->where('type', 'navigation')
|
||||
->pluck('link')
|
||||
->all();
|
||||
}
|
||||
|
||||
function adminOnlyNavigationLinks(): array
|
||||
{
|
||||
return [
|
||||
route('registries.index'),
|
||||
route('team.audit-log'),
|
||||
route('team.danger-zone'),
|
||||
route('security.cloud-tokens'),
|
||||
route('security.integration-tokens'),
|
||||
route('security.cloud-init-scripts'),
|
||||
route('terminal'),
|
||||
];
|
||||
}
|
||||
|
||||
it('lists every instance settings page for the root team', function () {
|
||||
$links = globalSearchNavigationLinks(Team::factory()->create(['id' => 0]));
|
||||
|
||||
expect($links)->toContain(
|
||||
route('settings.index'),
|
||||
route('settings.advanced'),
|
||||
route('settings.updates'),
|
||||
route('settings.backup'),
|
||||
route('settings.email'),
|
||||
route('settings.oauth'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not list instance settings pages for other teams', function () {
|
||||
$links = globalSearchNavigationLinks(Team::factory()->create());
|
||||
|
||||
expect(collect($links)->filter(fn (string $link) => str_contains($link, '/settings')))->toBeEmpty();
|
||||
});
|
||||
|
||||
it('lists team, security, notification and profile pages for a team owner', function () {
|
||||
$links = globalSearchNavigationLinks(Team::factory()->create());
|
||||
|
||||
expect($links)->toContain(
|
||||
route('analytics'),
|
||||
route('shared-variables.server.index'),
|
||||
route('team.member.index'),
|
||||
route('security.api-tokens'),
|
||||
route('notifications.discord'),
|
||||
route('notifications.telegram'),
|
||||
route('notifications.slack'),
|
||||
route('notifications.pushover'),
|
||||
route('notifications.webhook'),
|
||||
route('profile.appearance'),
|
||||
...adminOnlyNavigationLinks(),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides admin-only pages from team members', function () {
|
||||
$links = globalSearchNavigationLinks(Team::factory()->create(), 'member');
|
||||
|
||||
expect($links)->toContain(route('team.member.index'), route('security.api-tokens'))
|
||||
->not->toContain(...adminOnlyNavigationLinks());
|
||||
});
|
||||
|
||||
it('does not leak admin-only pages to a member through the team search cache', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
expect(globalSearchNavigationLinks($team))->toContain(...adminOnlyNavigationLinks());
|
||||
expect(globalSearchNavigationLinks($team, 'member'))->not->toContain(...adminOnlyNavigationLinks());
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function serverSettingsSearchGroups(): array
|
||||
{
|
||||
return [
|
||||
'Security' => [
|
||||
[
|
||||
'label' => 'Security',
|
||||
'route' => 'server.security.patches',
|
||||
'children' => [
|
||||
['label' => 'Server Patching', 'route' => 'server.security.patches'],
|
||||
['label' => 'Terminal Access', 'route' => 'server.security.terminal-access', 'navigate' => false],
|
||||
['label' => 'Hidden', 'route' => 'server.security', 'visible' => false],
|
||||
],
|
||||
],
|
||||
],
|
||||
'General' => [
|
||||
['label' => 'Proxy', 'route' => 'server.proxy'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
it('lists pages, visible child pages and in-page sections with their breadcrumbs', function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$items = settingsSearchItems(
|
||||
serverSettingsSearchGroups(),
|
||||
['server_uuid' => 'abc'],
|
||||
['server.proxy' => [['id' => 'proxy-config-section', 'label' => 'Configuration']]],
|
||||
);
|
||||
|
||||
expect($items)->toBe([
|
||||
['label' => 'Security', 'breadcrumb' => 'Security', 'search_text' => 'Security Security', 'href' => route('server.security.patches', ['server_uuid' => 'abc']), 'navigate' => true],
|
||||
['label' => 'Server Patching', 'breadcrumb' => 'Security · Security', 'search_text' => 'Server Patching Security · Security', 'href' => route('server.security.patches', ['server_uuid' => 'abc']), 'navigate' => true],
|
||||
['label' => 'Terminal Access', 'breadcrumb' => 'Security · Security', 'search_text' => 'Terminal Access Security · Security', 'href' => route('server.security.terminal-access', ['server_uuid' => 'abc']), 'navigate' => false],
|
||||
['label' => 'Proxy', 'breadcrumb' => 'General', 'search_text' => 'Proxy General', 'href' => route('server.proxy', ['server_uuid' => 'abc']), 'navigate' => true],
|
||||
['label' => 'Configuration', 'breadcrumb' => 'General · Proxy', 'search_text' => 'Configuration General · Proxy', 'href' => route('server.proxy', ['server_uuid' => 'abc']).'#proxy-config-section', 'navigate' => true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses full page loads when SPA navigation is disabled', function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_wire_navigate_enabled' => false]);
|
||||
|
||||
$items = settingsSearchItems(serverSettingsSearchGroups(), ['server_uuid' => 'abc']);
|
||||
|
||||
expect(collect($items)->pluck('navigate')->unique()->all())->toBe([false]);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
it('keeps bare list-style build args as named keys when merged into a map environment', function () {
|
||||
$environment = collect(['SENTRY_CONF' => '/etc/sentry', 'SENTRY_IMAGE' => 'getsentry/sentry'])
|
||||
->merge(['SENTRY_IMAGE', 'DOCKER_PLATFORM', 'FOO=bar']);
|
||||
|
||||
expect(convertToKeyValueCollection($environment)->all())->toBe([
|
||||
'SENTRY_CONF' => '/etc/sentry',
|
||||
'SENTRY_IMAGE' => 'getsentry/sentry',
|
||||
'DOCKER_PLATFORM' => null,
|
||||
'FOO' => 'bar',
|
||||
]);
|
||||
});
|
||||
@@ -49,3 +49,22 @@ it('keeps a finite collection alias limit for Docker Compose files', function ()
|
||||
|
||||
$application->oldRawParser();
|
||||
})->throws(RuntimeException::class, 'Maximum number of collection aliases (256) exceeded');
|
||||
|
||||
it('accepts Docker Compose files with more than 128 collection aliases during the safety check', function () {
|
||||
$services = collect(range(1, 129))
|
||||
->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n")
|
||||
->implode('');
|
||||
|
||||
validateDockerComposeForInjection("x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}");
|
||||
|
||||
expect(parseDockerComposeYaml("x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}"))
|
||||
->toHaveKey('services.service-129.image', 'alpine:latest');
|
||||
});
|
||||
|
||||
it('keeps a finite collection alias limit during the Docker Compose safety check', function () {
|
||||
$services = collect(range(1, Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES + 1))
|
||||
->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n")
|
||||
->implode('');
|
||||
|
||||
validateDockerComposeForInjection("x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}");
|
||||
})->throws(Exception::class, 'Maximum number of collection aliases (256) exceeded');
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->stack = seedBrowserResourceStack();
|
||||
$this->application = createBrowserApplication($this->stack, [
|
||||
'uuid' => 'app-palette-settings',
|
||||
'name' => 'Palette App',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the settings of the open resource in the command palette and switches scope with Tab', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application));
|
||||
$page->assertSee('Palette App')
|
||||
->assertMissing('[aria-label="Filter settings"]')
|
||||
->assertVisible('button:visible:has-text("Search page & global")');
|
||||
|
||||
$page->script("window.dispatchEvent(new Event('open-global-search'))");
|
||||
|
||||
$page->assertVisible('.command-palette-input')
|
||||
->assertAttribute('.command-palette-scope-option:has-text("Global")', 'aria-pressed', 'true')
|
||||
->assertAttribute('.command-palette-scope-option:has-text("This page")', 'aria-pressed', 'false')
|
||||
->type('.command-palette-input', 'runtime')
|
||||
->assertSeeIn('.command-palette-body', 'This page')
|
||||
->assertSeeIn('.command-palette-body', 'Runtime Logs')
|
||||
->assertSeeIn('.command-palette-body', 'Settings · General')
|
||||
->keys('.command-palette-input', 'Tab')
|
||||
->assertAttribute('.command-palette-scope-option:has-text("This page")', 'aria-pressed', 'true')
|
||||
->assertAttribute('.command-palette-scope-option:has-text("Global")', 'aria-pressed', 'false')
|
||||
->assertScript("document.activeElement === document.querySelector('.command-palette-input')")
|
||||
->type('.command-palette-input', 'zzz-nothing')
|
||||
->assertSeeIn('.command-palette-body', 'Nothing on this page')
|
||||
->type('.command-palette-input', 'runtime logs')
|
||||
->screenshot(filename: 'command-palette-this-page-scope')
|
||||
->click('.command-palette-body a.search-result-item')
|
||||
->assertPathEndsWith('/logs')
|
||||
->screenshot(filename: 'command-palette-opened-runtime-logs');
|
||||
});
|
||||
|
||||
it('does not show the scope switch on pages without a settings sidebar', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit('/dashboard');
|
||||
$page->assertDontSee('page & global');
|
||||
$page->script("window.dispatchEvent(new Event('open-global-search'))");
|
||||
|
||||
$page->assertVisible('.command-palette-input')
|
||||
->assertMissing('.command-palette-scope')
|
||||
->screenshot(filename: 'command-palette-without-scope');
|
||||
});
|
||||
|
||||
it('focuses the search input when the palette is opened from the mobile menu', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application))
|
||||
->on()->mobile();
|
||||
|
||||
$page->assertSee('Palette App')
|
||||
->click('button:visible:has-text("Open sidebar")')
|
||||
->assertSee('Search page & global')
|
||||
->screenshot(filename: 'command-palette-mobile-menu')
|
||||
->click('button:visible:has-text("Search")')
|
||||
->assertVisible('.command-palette-input')
|
||||
->wait(0.3)
|
||||
->assertScript("document.activeElement === document.querySelector('.command-palette-input')")
|
||||
->assertVisible('.command-palette-scope')
|
||||
->screenshot(filename: 'command-palette-mobile');
|
||||
});
|
||||
|
||||
function commandPaletteSettingsPageUrl(string $page, array $stack): string
|
||||
{
|
||||
return match ($page) {
|
||||
'instance settings' => '/settings/oauth',
|
||||
'team' => '/team',
|
||||
'keys and tokens' => '/security/private-key',
|
||||
'notifications' => '/notifications/email',
|
||||
'shared variables' => '/shared-variables',
|
||||
'destination' => '/destination/'.$stack['destination']->uuid,
|
||||
's3 storage' => '/storages/'.S3Storage::create([
|
||||
'team_id' => 0, 'name' => 'Palette S3', 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret',
|
||||
'bucket' => 'bucket', 'endpoint' => 'https://s3.example.com',
|
||||
])->uuid,
|
||||
'github app' => '/source/github/'.GithubApp::create([
|
||||
'name' => 'palette-app', 'organization' => 'acme', 'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'app_id' => 1234,
|
||||
'installation_id' => 5678, 'webhook_secret' => 'secret', 'private_key_id' => $stack['privateKey']->id,
|
||||
'team_id' => 0, 'is_system_wide' => false,
|
||||
])->uuid,
|
||||
'database backup' => (function () use ($stack): string {
|
||||
$database = createBrowserPostgresql($stack, ['uuid' => 'db-palette-backup']);
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'team_id' => 0, 'frequency' => '0 0 * * *', 'database_type' => $database->getMorphClass(),
|
||||
'database_id' => $database->id,
|
||||
]);
|
||||
|
||||
return "/project/{$stack['project']->uuid}/environment/{$stack['environment']->uuid}/database/{$database->uuid}/backups/{$backup->uuid}";
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
it('searches the sidebar pages of other settings pages', function (string $pageType, string $expectedLabel) {
|
||||
$url = commandPaletteSettingsPageUrl($pageType, $this->stack);
|
||||
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit($url);
|
||||
$page->assertVisible('button:visible:has-text("Search page & global")');
|
||||
$page->script("window.dispatchEvent(new Event('open-global-search'))");
|
||||
|
||||
$page->assertVisible('.command-palette-scope')
|
||||
->keys('.command-palette-input', 'Tab')
|
||||
->assertSeeIn('.command-palette-body', $expectedLabel)
|
||||
->screenshot(filename: 'command-palette-page-'.str($pageType)->slug());
|
||||
})->with([
|
||||
['instance settings', 'Updates'],
|
||||
['team', 'Members'],
|
||||
['keys and tokens', 'API Tokens'],
|
||||
['notifications', 'Telegram'],
|
||||
['shared variables', 'Environments'],
|
||||
['destination', 'Danger Zone'],
|
||||
['s3 storage', 'Resources'],
|
||||
['github app', 'Permissions'],
|
||||
['database backup', 'Retention'],
|
||||
]);
|
||||
|
||||
it('searches the sections of a page without a settings sidebar and scrolls to them', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit('/profile');
|
||||
$page->assertVisible('button:visible:has-text("Search page & global")');
|
||||
$page->script("window.dispatchEvent(new Event('open-global-search'))");
|
||||
|
||||
$page->assertVisible('.command-palette-scope')
|
||||
->keys('.command-palette-input', 'Tab')
|
||||
->type('.command-palette-input', 'two-factor')
|
||||
->assertSeeIn('.command-palette-body', 'Two-factor authentication')
|
||||
->click('.command-palette-body a.search-result-item')
|
||||
->assertMissing('.command-palette-input')
|
||||
->wait(1)
|
||||
->assertScript(<<<'JS'
|
||||
() => {
|
||||
const heading = [...document.querySelectorAll('.application-settings-section h2')]
|
||||
.find(el => el.textContent.includes('Two-factor authentication'));
|
||||
const top = heading.getBoundingClientRect().top;
|
||||
|
||||
return top >= 0 && top < window.innerHeight;
|
||||
}
|
||||
JS)
|
||||
->screenshot(filename: 'command-palette-profile-section');
|
||||
});
|
||||
|
||||
it('lists a section once when the sidebar already links to it', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application));
|
||||
$page->assertSee('Palette App');
|
||||
|
||||
expect($page->script("window.currentPageSearchItems().filter(item => item.label === 'Container labels').length"))->toBe(1);
|
||||
});
|
||||
Reference in new issue
Block a user