Merge branch 'next' into patch-3

This commit is contained in:
majcek210 2025-10-27 11:18:02 +01:00 committed by GitHub
commit 26b543810e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 2425 additions and 754 deletions

View file

@ -7,9 +7,9 @@
class CleanupRedis extends Command
{
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}';
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}';
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)';
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
public function handle()
{
@ -56,6 +56,13 @@ public function handle()
$deletedCount += $overlappingCleaned;
}
// Clean up stale cache locks (WithoutOverlapping middleware)
if ($this->option('clear-locks')) {
$this->info('Cleaning up stale cache locks...');
$locksCleaned = $this->cleanupCacheLocks($dryRun);
$deletedCount += $locksCleaned;
}
if ($dryRun) {
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
} else {
@ -273,4 +280,56 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
return $cleanedCount;
}
private function cleanupCacheLocks(bool $dryRun): int
{
$cleanedCount = 0;
// Use the default Redis connection (database 0) where cache locks are stored
$redis = Redis::connection('default');
// Get all keys matching WithoutOverlapping lock pattern
$allKeys = $redis->keys('*');
$lockKeys = [];
foreach ($allKeys as $key) {
// Match cache lock keys: they contain 'laravel-queue-overlap'
if (preg_match('/overlap/i', $key)) {
$lockKeys[] = $key;
}
}
if (empty($lockKeys)) {
$this->info(' No cache locks found.');
return 0;
}
$this->info(' Found '.count($lockKeys).' cache lock(s)');
foreach ($lockKeys as $lockKey) {
// Check TTL to identify stale locks
$ttl = $redis->ttl($lockKey);
// TTL = -1 means no expiration (stale lock!)
// TTL = -2 means key doesn't exist
// TTL > 0 means lock is valid and will expire
if ($ttl === -1) {
if ($dryRun) {
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
} else {
$redis->del($lockKey);
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
}
$cleanedCount++;
} elseif ($ttl > 0) {
$this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
}
}
if ($cleanedCount === 0) {
$this->info(' No stale locks found (all locks have expiration set)');
}
return $cleanedCount;
}
}

View file

@ -73,7 +73,7 @@ public function handle()
$this->cleanupUnusedNetworkFromCoolifyProxy();
try {
$this->call('cleanup:redis');
$this->call('cleanup:redis', ['--clear-locks' => true]);
} catch (\Throwable $e) {
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
}

View file

@ -459,7 +459,7 @@ private function decide_what_to_do()
private function post_deployment()
{
GetContainersStatus::dispatch($this->server);
$this->next(ApplicationDeploymentStatus::FINISHED->value);
$this->completeDeployment();
if ($this->pull_request_id !== 0) {
if ($this->application->is_github_based()) {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
@ -517,6 +517,10 @@ private function deploy_dockerimage_buildpack()
$this->generate_image_names();
$this->prepare_builder_image();
$this->generate_compose_file();
// Save runtime environment variables (including empty .env file if no variables defined)
$this->save_runtime_environment_variables();
$this->rolling_update();
}
@ -1004,7 +1008,7 @@ private function just_restart()
$this->generate_image_names();
$this->check_image_locally_or_remotely();
$this->should_skip_build();
$this->next(ApplicationDeploymentStatus::FINISHED->value);
$this->completeDeployment();
}
private function should_skip_build()
@ -1222,9 +1226,9 @@ private function save_runtime_environment_variables()
// Handle empty environment variables
if ($environment_variables->isEmpty()) {
// For Docker Compose, we need to create an empty .env file
// For Docker Compose and Docker Image, we need to create an empty .env file
// because we always reference it in the compose file
if ($this->build_pack === 'dockercompose') {
if ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerimage') {
$this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).');
// Create empty .env file
@ -1628,7 +1632,7 @@ private function health_check()
return;
}
if ($this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry('Custom healthcheck found, skipping default healthcheck.');
$this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.');
}
if ($this->container_name) {
$counter = 1;
@ -2354,16 +2358,22 @@ private function generate_compose_file()
];
// Always use .env file
$docker_compose['services'][$this->container_name]['env_file'] = ['.env'];
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
'CMD-SHELL',
$this->generate_healthcheck_commands(),
],
'interval' => $this->application->health_check_interval.'s',
'timeout' => $this->application->health_check_timeout.'s',
'retries' => $this->application->health_check_retries,
'start_period' => $this->application->health_check_start_period.'s',
];
// Only add Coolify healthcheck if no custom HEALTHCHECK found in Dockerfile
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
// If healthcheck is disabled, no healthcheck will be added
if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) {
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
'CMD-SHELL',
$this->generate_healthcheck_commands(),
],
'interval' => $this->application->health_check_interval.'s',
'timeout' => $this->application->health_check_timeout.'s',
'retries' => $this->application->health_check_retries,
'start_period' => $this->application->health_check_start_period.'s',
];
}
if (! is_null($this->application->limits_cpuset)) {
data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset);
@ -3013,9 +3023,7 @@ private function stop_running_container(bool $force = false)
$this->application_deployment_queue->addLogEntry('----------------------------------------');
}
$this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.');
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::FAILED->value,
]);
$this->failDeployment();
$this->graceful_shutdown_container($this->container_name);
}
}
@ -3649,42 +3657,116 @@ private function checkForCancellation(): void
}
}
private function next(string $status)
/**
* Transition deployment to a new status with proper validation and side effects.
* This is the single source of truth for status transitions.
*/
private function transitionToStatus(ApplicationDeploymentStatus $status): void
{
// Refresh to get latest status
$this->application_deployment_queue->refresh();
// Never allow changing status from FAILED or CANCELLED_BY_USER to anything else
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
$this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview));
if ($this->isInTerminalState()) {
return;
}
$this->updateDeploymentStatus($status);
$this->handleStatusTransition($status);
queue_next_deployment($this->application);
}
/**
* Check if deployment is in a terminal state (FAILED or CANCELLED).
* Terminal states cannot be changed.
*/
private function isInTerminalState(): bool
{
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
return true;
}
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
// Job was cancelled, stop execution
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
throw new \RuntimeException('Deployment cancelled by user', 69420);
}
return false;
}
/**
* Update the deployment status in the database.
*/
private function updateDeploymentStatus(ApplicationDeploymentStatus $status): void
{
$this->application_deployment_queue->update([
'status' => $status,
'status' => $status->value,
]);
}
queue_next_deployment($this->application);
/**
* Execute status-specific side effects (events, notifications, additional deployments).
*/
private function handleStatusTransition(ApplicationDeploymentStatus $status): void
{
match ($status) {
ApplicationDeploymentStatus::FINISHED => $this->handleSuccessfulDeployment(),
ApplicationDeploymentStatus::FAILED => $this->handleFailedDeployment(),
default => null,
};
}
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
event(new ApplicationConfigurationChanged($this->application->team()->id));
/**
* Handle side effects when deployment succeeds.
*/
private function handleSuccessfulDeployment(): void
{
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}
$this->application->environment->project->team?->notify(new DeploymentSuccess($this->application, $this->deployment_uuid, $this->preview));
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}
$this->sendDeploymentNotification(DeploymentSuccess::class);
}
/**
* Handle side effects when deployment fails.
*/
private function handleFailedDeployment(): void
{
$this->sendDeploymentNotification(DeploymentFailed::class);
}
/**
* Send deployment status notification to the team.
*/
private function sendDeploymentNotification(string $notificationClass): void
{
$this->application->environment->project->team?->notify(
new $notificationClass($this->application, $this->deployment_uuid, $this->preview)
);
}
/**
* Complete deployment successfully.
* Sends success notification and triggers additional deployments if needed.
*/
private function completeDeployment(): void
{
$this->transitionToStatus(ApplicationDeploymentStatus::FINISHED);
}
/**
* Fail the deployment.
* Sends failure notification and queues next deployment.
*/
private function failDeployment(): void
{
$this->transitionToStatus(ApplicationDeploymentStatus::FAILED);
}
public function failed(Throwable $exception): void
{
$this->next(ApplicationDeploymentStatus::FAILED->value);
$this->failDeployment();
$this->application_deployment_queue->addLogEntry('Oops something is not okay, are you okay? 😢', 'stderr');
if (str($exception->getMessage())->isNotEmpty()) {
$this->application_deployment_queue->addLogEntry($exception->getMessage(), 'stderr');

View file

@ -52,7 +52,8 @@ public function middleware(): array
{
return [
(new WithoutOverlapping('scheduled-job-manager'))
->releaseAfter(60), // Release the lock after 60 seconds if job fails
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
->dontRelease(), // Don't re-queue on lock conflict
];
}

View file

@ -107,7 +107,7 @@ public function mount()
if ($this->selectedServerType === 'remote') {
if ($this->privateKeys->isEmpty()) {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
$this->privateKeys = PrivateKey::ownedAndOnlySShKeys(['name'])->where('id', '!=', 0)->get();
}
if ($this->servers->isEmpty()) {
$this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
@ -186,7 +186,7 @@ public function setServerType(string $type)
return $this->validateServer('localhost');
} elseif ($this->selectedServerType === 'remote') {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
$this->privateKeys = PrivateKey::ownedAndOnlySShKeys(['name'])->where('id', '!=', 0)->get();
// Auto-select first key if available for better UX
if ($this->privateKeys->count() > 0) {
$this->selectedExistingPrivateKey = $this->privateKeys->first()->id;

View file

@ -58,6 +58,11 @@ public function checkStatus()
}
}
public function manualCheckStatus()
{
$this->checkStatus();
}
public function force_deploy_without_cache()
{
$this->authorize('deploy', $this->application);

View file

@ -62,6 +62,11 @@ public function checkStatus()
}
}
public function manualCheckStatus()
{
$this->checkStatus();
}
public function mount()
{
$this->parameters = get_route_parameters();

File diff suppressed because one or more lines are too long

View file

@ -54,6 +54,11 @@ public function checkStatus()
}
}
public function manualCheckStatus()
{
$this->checkStatus();
}
public function serviceChecked()
{
try {

View file

@ -74,12 +74,16 @@ class ByHetzner extends Component
#[Locked]
public Collection $saved_cloud_init_scripts;
public bool $from_onboarding = false;
public function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
$this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id;
}
@ -131,7 +135,7 @@ public function handleTokenAdded($tokenId)
public function handlePrivateKeyCreated($keyId)
{
// Refresh private keys list
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
// Auto-select the new key
$this->private_key_id = $keyId;
@ -246,12 +250,6 @@ private function loadHetznerData(string $token)
// Get images and sort by name
$images = $hetznerService->getImages();
ray('Raw images from Hetzner API', [
'total_count' => count($images),
'types' => collect($images)->pluck('type')->unique()->values(),
'sample' => array_slice($images, 0, 3),
]);
$this->images = collect($images)
->filter(function ($image) {
// Only system images
@ -269,20 +267,8 @@ private function loadHetznerData(string $token)
->sortBy('name')
->values()
->toArray();
ray('Filtered images', [
'filtered_count' => count($this->images),
'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(),
]);
// Load SSH keys from Hetzner
$this->hetznerSshKeys = $hetznerService->getSshKeys();
ray('Hetzner SSH Keys', [
'total_count' => count($this->hetznerSshKeys),
'keys' => $this->hetznerSshKeys,
]);
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
@ -299,9 +285,9 @@ private function getCpuVendorInfo(array $serverType): ?string
} elseif (str_starts_with($name, 'cpx')) {
return 'AMD EPYC™';
} elseif (str_starts_with($name, 'cx')) {
return 'Intel® Xeon®';
return 'Intel®/AMD';
} elseif (str_starts_with($name, 'cax')) {
return 'Ampere® Altra®';
return 'Ampere®';
}
return null;
@ -574,6 +560,11 @@ public function submit()
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($this->from_onboarding) {
// When in onboarding, use wire:navigate for proper modal handling
return $this->redirect(route('server.show', $server->uuid));
}
return redirect()->route('server.show', $server->uuid);
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -85,19 +85,8 @@ public function submit()
// Handle allowed IPs with subnet support and 0.0.0.0 special case
$this->allowed_ips = str($this->allowed_ips)->replaceEnd(',', '')->trim();
// Check if user entered 0.0.0.0 or left field empty (both allow access from anywhere)
$allowsFromAnywhere = false;
if (empty($this->allowed_ips)) {
$allowsFromAnywhere = true;
} elseif ($this->allowed_ips === '0.0.0.0' || str_contains($this->allowed_ips, '0.0.0.0')) {
$allowsFromAnywhere = true;
}
// Check if it's 0.0.0.0 (allow all) or empty
if ($this->allowed_ips === '0.0.0.0' || empty($this->allowed_ips)) {
// Keep as is - empty means no restriction, 0.0.0.0 means allow all
} else {
// Validate and clean up the entries
// Only validate and clean up if we have IPs and it's not 0.0.0.0 (allow all)
if (! empty($this->allowed_ips) && ! in_array('0.0.0.0', array_map('trim', explode(',', $this->allowed_ips)))) {
$invalidEntries = [];
$validEntries = str($this->allowed_ips)->trim()->explode(',')->map(function ($entry) use (&$invalidEntries) {
$entry = str($entry)->trim()->toString();
@ -133,7 +122,6 @@ public function submit()
return;
}
// Also check if we have no valid entries after filtering
if ($validEntries->isEmpty()) {
$this->dispatch('error', 'No valid IP addresses or subnets provided');
@ -144,14 +132,6 @@ public function submit()
}
$this->instantSave();
// Show security warning if allowing access from anywhere
if ($allowsFromAnywhere) {
$message = empty($this->allowed_ips)
? 'Empty IP allowlist allows API access from anywhere.<br><br>This is not recommended for production environments!'
: 'Using 0.0.0.0 allows API access from anywhere.<br><br>This is not recommended for production environments!';
$this->dispatch('warning', $message);
}
} catch (\Exception $e) {
return handleError($e, $this);
}

View file

@ -47,19 +47,19 @@ class Change extends Component
public int $customPort;
public int $appId;
public ?int $appId = null;
public int $installationId;
public ?int $installationId = null;
public string $clientId;
public ?string $clientId = null;
public string $clientSecret;
public ?string $clientSecret = null;
public string $webhookSecret;
public ?string $webhookSecret = null;
public bool $isSystemWide;
public int $privateKeyId;
public ?int $privateKeyId = null;
public ?string $contents = null;
@ -78,16 +78,16 @@ class Change extends Component
'htmlUrl' => 'required|string',
'customUser' => 'required|string',
'customPort' => 'required|int',
'appId' => 'required|int',
'installationId' => 'required|int',
'clientId' => 'required|string',
'clientSecret' => 'required|string',
'webhookSecret' => 'required|string',
'appId' => 'nullable|int',
'installationId' => 'nullable|int',
'clientId' => 'nullable|string',
'clientSecret' => 'nullable|string',
'webhookSecret' => 'nullable|string',
'isSystemWide' => 'required|bool',
'contents' => 'nullable|string',
'metadata' => 'nullable|string',
'pullRequests' => 'nullable|string',
'privateKeyId' => 'required|int',
'privateKeyId' => 'nullable|int',
];
public function boot()
@ -148,47 +148,48 @@ public function checkPermissions()
try {
$this->authorize('view', $this->github_app);
// Validate required fields before attempting to fetch permissions
$missingFields = [];
if (! $this->github_app->app_id) {
$missingFields[] = 'App ID';
}
if (! $this->github_app->private_key_id) {
$missingFields[] = 'Private Key';
}
if (! empty($missingFields)) {
$fieldsList = implode(', ', $missingFields);
$this->dispatch('error', "Cannot fetch permissions. Please set the following required fields first: {$fieldsList}");
return;
}
// Verify the private key exists and is accessible
if (! $this->github_app->privateKey) {
$this->dispatch('error', 'Private Key not found. Please select a valid private key.');
return;
}
GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->dispatch('success', 'Github App permissions updated.');
} catch (\Throwable $e) {
// Provide better error message for unsupported key formats
$errorMessage = $e->getMessage();
if (str_contains($errorMessage, 'DECODER routines::unsupported') ||
str_contains($errorMessage, 'parse your key')) {
$this->dispatch('error', 'The selected private key format is not supported for GitHub Apps. <br><br>Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY). <br><br>OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.');
return;
}
return handleError($e, $this);
}
}
// public function check()
// {
// Need administration:read:write permission
// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#list-self-hosted-runners-for-a-repository
// $github_access_token = generateGithubInstallationToken($this->github_app);
// $repositories = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/installation/repositories?per_page=100");
// $runners_by_repository = collect([]);
// $repositories = $repositories->json()['repositories'];
// foreach ($repositories as $repository) {
// $runners_downloads = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/downloads");
// $runners = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners");
// $token = Http::withHeaders([
// 'Authorization' => "Bearer $github_access_token",
// 'Accept' => 'application/vnd.github+json'
// ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/registration-token");
// $token = $token->json();
// $remove_token = Http::withHeaders([
// 'Authorization' => "Bearer $github_access_token",
// 'Accept' => 'application/vnd.github+json'
// ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/remove-token");
// $remove_token = $remove_token->json();
// $runners_by_repository->put($repository['full_name'], [
// 'token' => $token,
// 'remove_token' => $remove_token,
// 'runners' => $runners->json(),
// 'runners_downloads' => $runners_downloads->json()
// ]);
// }
// }
public function mount()
{
try {
@ -340,10 +341,13 @@ public function createGithubAppManually()
$this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->github_app->app_id = '1234567890';
$this->github_app->installation_id = '1234567890';
$this->github_app->app_id = 1234567890;
$this->github_app->installation_id = 1234567890;
$this->github_app->save();
$this->dispatch('success', 'Github App updated.');
// Redirect to avoid Livewire morphing issues when view structure changes
return redirect()->route('source.github.show', ['github_app_uuid' => $this->github_app->uuid])
->with('success', 'Github App updated. You can now configure the details.');
}
public function instantSave()

View file

@ -50,11 +50,9 @@ public function createGitHubApp()
'html_url' => $this->html_url,
'custom_user' => $this->custom_user,
'custom_port' => $this->custom_port,
'is_system_wide' => $this->is_system_wide,
'team_id' => currentTeam()->id,
];
if (isCloud()) {
$payload['is_system_wide'] = $this->is_system_wide;
}
$github_app = GithubApp::create($payload);
if (session('from')) {
session(['from' => session('from') + ['source_id' => $github_app->id]]);

View file

@ -1804,7 +1804,22 @@ public function getFilesFromServer(bool $isInit = false)
public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false)
{
$dockerfile = str($dockerfile)->trim()->explode("\n");
if (str($dockerfile)->contains('HEALTHCHECK') && ($this->isHealthcheckDisabled() || $isInit)) {
$hasHealthcheck = str($dockerfile)->contains('HEALTHCHECK');
// Always check if healthcheck was removed, regardless of health_check_enabled setting
if (! $hasHealthcheck && $this->custom_healthcheck_found) {
// HEALTHCHECK was removed from Dockerfile, reset to defaults
$this->custom_healthcheck_found = false;
$this->health_check_interval = 5;
$this->health_check_timeout = 5;
$this->health_check_retries = 10;
$this->health_check_start_period = 5;
$this->save();
return;
}
if ($hasHealthcheck && ($this->isHealthcheckDisabled() || $isInit)) {
$healthcheckCommand = null;
$lines = $dockerfile->toArray();
foreach ($lines as $line) {

View file

@ -12,6 +12,7 @@ class GithubApp extends BaseModel
protected $casts = [
'is_public' => 'boolean',
'is_system_wide' => 'boolean',
'type' => 'string',
];

View file

@ -88,6 +88,16 @@ public static function ownedByCurrentTeam(array $select = ['*'])
return self::whereTeamId($teamId)->select($selectArray->all());
}
public static function ownedAndOnlySShKeys(array $select = ['*'])
{
$teamId = currentTeam()->id;
$selectArray = collect($select)->concat(['id']);
return self::whereTeamId($teamId)
->where('is_git_related', false)
->select($selectArray->all());
}
public static function validatePrivateKey($privateKey)
{
try {

View file

@ -88,7 +88,14 @@ public function getImages(): array
public function getServerTypes(): array
{
return $this->requestPaginated('get', '/server_types', 'server_types');
$types = $this->requestPaginated('get', '/server_types', 'server_types');
// Filter out entries where "deprecated" is explicitly true
$filtered = array_filter($types, function ($type) {
return ! (isset($type['deprecated']) && $type['deprecated'] === true);
});
return array_values($filtered);
}
public function getSshKeys(): array

View file

@ -51,6 +51,8 @@
const SPECIFIC_SERVICES = [
'quay.io/minio/minio',
'minio/minio',
'ghcr.io/coollabsio/minio',
'coollabsio/minio',
'svhd/logto',
];

View file

@ -41,7 +41,13 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth
$database = new StandaloneRedis;
$database->uuid = (new Cuid2);
$database->name = 'redis-database-'.$database->uuid;
$redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
if ($otherData && isset($otherData['redis_password'])) {
$redis_password = $otherData['redis_password'];
unset($otherData['redis_password']);
}
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();

View file

@ -358,6 +358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
{
$uuid = data_get($resource, 'uuid');
$compose = data_get($resource, 'docker_compose_raw');
// Store original compose for later use to update docker_compose_raw with content removed
$originalCompose = $compose;
if (! $compose) {
return collect([]);
}
@ -1297,7 +1299,34 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
return array_search($key, $customOrder);
});
$resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
// Update docker_compose_raw to remove content: from volumes only
// 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);
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
foreach ($originalYaml['services'] as $serviceName => &$service) {
if (isset($service['volumes'])) {
foreach ($service['volumes'] as $key => &$volume) {
if (is_array($volume)) {
unset($volume['content']);
unset($volume['isDirectory']);
unset($volume['is_directory']);
}
}
}
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (\Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview');
$resource->save();
@ -1309,6 +1338,8 @@ function serviceParser(Service $resource): Collection
{
$uuid = data_get($resource, 'uuid');
$compose = data_get($resource, 'docker_compose_raw');
// Store original compose for later use to update docker_compose_raw with content removed
$originalCompose = $compose;
if (! $compose) {
return collect([]);
}
@ -2220,7 +2251,34 @@ function serviceParser(Service $resource): Collection
return array_search($key, $customOrder);
});
$resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
// Update docker_compose_raw to remove content: from volumes only
// 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);
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
foreach ($originalYaml['services'] as $serviceName => &$service) {
if (isset($service['volumes'])) {
foreach ($service['volumes'] as $key => &$volume) {
if (is_array($volume)) {
unset($volume['content']);
unset($volume['isDirectory']);
unset($volume['is_directory']);
}
}
}
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (\Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
}
data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview');
$resource->save();

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.437',
'version' => '4.0.0-beta.438',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -104,7 +104,7 @@ services:
networks:
- coolify
minio:
image: minio/minio:latest
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
pull_policy: always
container_name: coolify-minio
command: server /data --console-address ":9001"

View file

@ -0,0 +1,4 @@
<svg width="240" height="240" viewBox="0 0 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M240 224.762C240 233.012 233.25 239.762 225 239.762H15C6.75 239.762 0 233.012 0 224.762V134.762C0 126.512 4.77 114.993 10.61 109.153L109.39 10.3725C115.22 4.5425 124.77 4.5425 130.6 10.3725L229.39 109.162C235.22 114.992 240 126.522 240 134.772V224.772V224.762Z" fill="#F2F4F9"/>
<path d="M229.39 109.153L130.61 10.3725C124.78 4.5425 115.23 4.5425 109.4 10.3725L10.61 109.153C4.78 114.983 0 126.512 0 134.762V224.762C0 233.012 6.75 239.762 15 239.762H107.27L66.64 199.132C64.55 199.852 62.32 200.262 60 200.262C48.7 200.262 39.5 191.062 39.5 179.762C39.5 168.462 48.7 159.262 60 159.262C71.3 159.262 80.5 168.462 80.5 179.762C80.5 182.092 80.09 184.322 79.37 186.412L111 218.042V102.162C104.2 98.8225 99.5 91.8425 99.5 83.7725C99.5 72.4725 108.7 63.2725 120 63.2725C131.3 63.2725 140.5 72.4725 140.5 83.7725C140.5 91.8425 135.8 98.8225 129 102.162V183.432L160.46 151.972C159.84 150.012 159.5 147.932 159.5 145.772C159.5 134.472 168.7 125.272 180 125.272C191.3 125.272 200.5 134.472 200.5 145.772C200.5 157.072 191.3 166.272 180 166.272C177.5 166.272 175.12 165.802 172.91 164.982L129 208.892V239.772H225C233.25 239.772 240 233.022 240 224.772V134.772C240 126.522 235.23 115.002 229.39 109.162V109.153Z" fill="#18BCF2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/svgs/metamcp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

1
public/svgs/rivet.svg Normal file
View file

@ -0,0 +1 @@
<svg width="128" height="128" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="126" height="126" rx="44" fill="#0F0F0F"/><rect x="18.25" y="18.25" width="91.5" height="91.5" rx="25.75" stroke="#F0F0F0" stroke-width="8.5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M57.694 43.098c0-.622-.505-1.126-1.127-1.126h-8.444a5.114 5.114 0 0 0-5.112 5.111v33.824a5.114 5.114 0 0 0 5.112 5.112h8.444c.622 0 1.127-.505 1.127-1.127V43.098Zm24.424 27.869c-1.238-2.222-4.047-4.026-6.27-4.026H62.923c-.684 0-.93.555-.549 1.239l7.703 13.822c1.239 2.223 4.048 4.026 6.27 4.026h12.927c.683 0 .93-.555.548-1.239l-7.703-13.822Zm.538-18.718c0-5.672-4.605-10.277-10.277-10.277H63.31a1.21 1.21 0 0 0-1.209 1.209v18.137c0 .667.542 1.209 1.21 1.209h9.068c5.672 0 10.277-4.605 10.277-10.278Z" fill="#F0F0F0"/></svg>

After

Width:  |  Height:  |  Size: 819 B

10
public/svgs/siyuan.svg Normal file
View file

@ -0,0 +1,10 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<title></title>
<g id="icomoon-ignore">
</g>
<path fill="#d23f31" d="M37.052 371.676l269.857-269.857v550.507l-269.857 269.857z"></path>
<path fill="#3b3e43" d="M306.909 101.818l205.091 205.091v550.507l-205.091-205.091z"></path>
<path fill="#d23f31" d="M512 306.909l205.091-205.091v550.507l-205.091 205.091z"></path>
<path fill="#3b3e43" d="M717.091 101.818l269.857 269.857v550.507l-269.857-269.857z"></path>
</svg>

After

Width:  |  Height:  |  Size: 554 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 312 KiB

View file

@ -14,14 +14,156 @@
@if ($multiple)
{{-- Multiple Selection Mode with Alpine.js --}}
<div x-data="{
open: false,
search: '',
selected: @entangle($modelBinding).live,
options: [],
filteredOptions: [],
init() {
this.options = Array.from(this.$refs.datalist.querySelectorAll('option')).map(opt => {
// Try to parse as integer, fallback to string
let value = opt.value;
const intValue = parseInt(value, 10);
if (!isNaN(intValue) && intValue.toString() === value) {
value = intValue;
}
return {
value: value,
text: opt.textContent.trim()
};
});
this.filteredOptions = this.options;
// Ensure selected is always an array
if (!Array.isArray(this.selected)) {
this.selected = [];
}
},
filterOptions() {
if (!this.search) {
this.filteredOptions = this.options;
return;
}
const searchLower = this.search.toLowerCase();
this.filteredOptions = this.options.filter(opt =>
opt.text.toLowerCase().includes(searchLower)
);
},
toggleOption(value) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
this.selected = [];
}
const index = this.selected.indexOf(value);
if (index > -1) {
this.selected.splice(index, 1);
} else {
this.selected.push(value);
}
this.search = '';
this.filterOptions();
// Focus input after selection
this.$refs.searchInput.focus();
},
removeOption(value, event) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
this.selected = [];
return;
}
// Prevent triggering container click
event.stopPropagation();
const index = this.selected.indexOf(value);
if (index > -1) {
this.selected.splice(index, 1);
}
},
isSelected(value) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
return false;
}
return this.selected.includes(value);
},
getSelectedText(value) {
const option = this.options.find(opt => opt.value == value);
return option ? option.text : value;
}
}" @click.outside="open = false" class="relative">
{{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text px-1 focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" wire:loading.class="opacity-50"
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
{{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value">
<button type="button" @click.stop="removeOption(value, $event)"
:disabled="{{ $disabled ? 'true' : 'false' }}"
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs bg-coolgray-200 dark:bg-coolgray-700 rounded whitespace-nowrap {{ $disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400' }}"
aria-label="Remove">
<span x-text="getSelectedText(value)" class="max-w-[200px] truncate"></span>
</button>
</template>
{{-- Search Input (Borderless, Inside Container) --}}
<input type="text" x-model="search" x-ref="searchInput" @input="filterOptions()" @focus="open = true"
@keydown.escape="open = false" :placeholder="(Array.isArray(selected) && selected.length > 0) ? '' :
{{ json_encode($placeholder ?: 'Search...') }}" @required($required) @readonly($readonly)
@disabled($disabled) @if ($autofocus) autofocus @endif
class="flex-1 min-w-[120px] text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-black dark:text-white" />
</div>
{{-- Dropdown Options --}}
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
<template x-if="filteredOptions.length === 0">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
No options found
</div>
</template>
<template x-for="option in filteredOptions" :key="option.value">
<div @click="toggleOption(option.value)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-3"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': isSelected(option.value) }">
<input type="checkbox" :checked="isSelected(option.value)"
class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:bg-coolgray-100 text-black dark:text-white checked:bg-white dark:checked:bg-coolgray-100 focus:ring-coollabs dark:focus:ring-warning pointer-events-none"
tabindex="-1">
<span class="text-sm flex-1" x-text="option.text"></span>
</div>
</template>
</div>
{{-- Hidden datalist for options --}}
<datalist x-ref="datalist" style="display: none;">
{{ $slot }}
</datalist>
</div>
@else
{{-- Single Selection Mode with Alpine.js --}}
<div x-data="{
open: false,
search: '',
selected: @entangle($modelBinding).live,
selected: @entangle(($attributes->whereStartsWith('wire:model')->first() ? $attributes->wire('model')->value() : $modelBinding)).live,
options: [],
filteredOptions: [],
init() {
this.options = Array.from(this.$refs.datalist.querySelectorAll('option')).map(opt => {
// Skip disabled options
if (opt.disabled) {
return null;
}
// Try to parse as integer, fallback to string
let value = opt.value;
const intValue = parseInt(value, 10);
@ -32,14 +174,10 @@
value: value,
text: opt.textContent.trim()
};
});
}).filter(opt => opt !== null);
this.filteredOptions = this.options;
// Ensure selected is always an array
if (!Array.isArray(this.selected)) {
this.selected = [];
}
},
filterOptions() {
if (!this.search) {
this.filteredOptions = this.options;
@ -50,243 +188,97 @@
opt.text.toLowerCase().includes(searchLower)
);
},
toggleOption(value) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
this.selected = [];
}
const index = this.selected.indexOf(value);
if (index > -1) {
this.selected.splice(index, 1);
} else {
this.selected.push(value);
}
selectOption(value) {
this.selected = value;
this.search = '';
this.open = false;
this.filterOptions();
// Focus input after selection
this.$refs.searchInput.focus();
},
removeOption(value, event) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
this.selected = [];
return;
}
// Prevent triggering container click
event.stopPropagation();
const index = this.selected.indexOf(value);
if (index > -1) {
this.selected.splice(index, 1);
}
openDropdown() {
if ({{ $disabled ? 'true' : 'false' }}) return;
this.open = true;
this.$nextTick(() => {
if (this.$refs.searchInput) {
this.$refs.searchInput.focus();
}
});
},
isSelected(value) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
return false;
}
return this.selected.includes(value);
getSelectedText() {
if (!this.selected || this.selected === 'default') return '';
const option = this.options.find(opt => opt.value == this.selected);
return option ? option.text : this.selected;
},
getSelectedText(value) {
const option = this.options.find(opt => opt.value == value);
return option ? option.text : value;
isDefaultValue() {
return !this.selected || this.selected === 'default' || this.selected === '';
}
}" @click.outside="open = false" class="relative">
{{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text px-1 focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{
{{-- Hidden input for form validation --}}
<input type="hidden" :value="selected" @required($required) />
{{-- Input Container --}}
<div @click="openDropdown()"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}"
wire:loading.class="opacity-50"
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
}" wire:loading.class="opacity-50" wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
{{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value">
<button
type="button"
@click.stop="removeOption(value, $event)"
:disabled="{{ $disabled ? 'true' : 'false' }}"
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs bg-coolgray-200 dark:bg-coolgray-700 rounded whitespace-nowrap {{ $disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400' }}"
aria-label="Remove">
<span x-text="getSelectedText(value)" class="max-w-[200px] truncate"></span>
{{-- Display Selected Value or Search Input --}}
<div class="flex-1 flex items-center min-w-0 px-1">
<template x-if="!isDefaultValue() && !open">
<span class="text-sm flex-1 truncate text-black dark:text-white px-2"
x-text="getSelectedText()"></span>
</template>
<input type="text" x-show="isDefaultValue() || open" x-model="search" x-ref="searchInput"
@input="filterOptions()" @focus="open = true" @keydown.escape="open = false"
:placeholder="{{ json_encode($placeholder ?: 'Search...') }}" @readonly($readonly)
@disabled($disabled) @if ($autofocus) autofocus @endif
class="flex-1 text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-black dark:text-white px-2" />
</div>
{{-- Dropdown Arrow --}}
<button type="button" @click.stop="open = !open" :disabled="{{ $disabled ? 'true' : 'false' }}"
class="shrink-0 text-neutral-400 px-2 {{ $disabled ? 'cursor-not-allowed' : 'cursor-pointer' }}">
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</template>
</div>
{{-- Search Input (Borderless, Inside Container) --}}
<input type="text" x-model="search" x-ref="searchInput" @input="filterOptions()" @focus="open = true"
@keydown.escape="open = false"
:placeholder="(Array.isArray(selected) && selected.length > 0) ? '' :
{{ json_encode($placeholder ?: 'Search...') }}"
@required($required) @readonly($readonly) @disabled($disabled) @if ($autofocus)
autofocus
{{-- Dropdown Options --}}
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
<template x-if="filteredOptions.length === 0">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
No options found
</div>
</template>
<template x-for="option in filteredOptions" :key="option.value">
<div @click="selectOption(option.value)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selected == option.value }">
<span class="text-sm" x-text="option.text"></span>
</div>
</template>
</div>
{{-- Hidden datalist for options --}}
<datalist x-ref="datalist" style="display: none;">
{{ $slot }}
</datalist>
</div>
@endif
class="flex-1 min-w-[120px] text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-black dark:text-white"
/>
</div>
{{-- Dropdown Options --}}
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
<template x-if="filteredOptions.length === 0">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
No options found
</div>
</template>
<template x-for="option in filteredOptions" :key="option.value">
<div @click="toggleOption(option.value)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-3"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': isSelected(option.value) }">
<input type="checkbox" :checked="isSelected(option.value)"
class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:bg-coolgray-100 text-black dark:text-white checked:bg-white dark:checked:bg-coolgray-100 focus:ring-coollabs dark:focus:ring-warning pointer-events-none"
tabindex="-1">
<span class="text-sm flex-1" x-text="option.text"></span>
</div>
</template>
</div>
{{-- Hidden datalist for options --}}
<datalist x-ref="datalist" style="display: none;">
{{ $slot }}
</datalist>
</div>
@else
{{-- Single Selection Mode with Alpine.js --}}
<div x-data="{
open: false,
search: '',
selected: @entangle(($attributes->whereStartsWith('wire:model')->first() ? $attributes->wire('model')->value() : $modelBinding)).live,
options: [],
filteredOptions: [],
init() {
this.options = Array.from(this.$refs.datalist.querySelectorAll('option')).map(opt => {
// Skip disabled options
if (opt.disabled) {
return null;
}
// Try to parse as integer, fallback to string
let value = opt.value;
const intValue = parseInt(value, 10);
if (!isNaN(intValue) && intValue.toString() === value) {
value = intValue;
}
return {
value: value,
text: opt.textContent.trim()
};
}).filter(opt => opt !== null);
this.filteredOptions = this.options;
},
filterOptions() {
if (!this.search) {
this.filteredOptions = this.options;
return;
}
const searchLower = this.search.toLowerCase();
this.filteredOptions = this.options.filter(opt =>
opt.text.toLowerCase().includes(searchLower)
);
},
selectOption(value) {
this.selected = value;
this.search = '';
this.open = false;
this.filterOptions();
},
openDropdown() {
if ({{ $disabled ? 'true' : 'false' }}) return;
this.open = true;
this.$nextTick(() => {
if (this.$refs.searchInput) {
this.$refs.searchInput.focus();
}
});
},
getSelectedText() {
if (!this.selected || this.selected === 'default') return '';
const option = this.options.find(opt => opt.value == this.selected);
return option ? option.text : this.selected;
},
isDefaultValue() {
return !this.selected || this.selected === 'default' || this.selected === '';
}
}" @click.outside="open = false" class="relative">
{{-- Hidden input for form validation --}}
<input type="hidden" :value="selected" @required($required) />
{{-- Input Container --}}
<div @click="openDropdown()"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}"
wire:loading.class="opacity-50"
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
{{-- Display Selected Value or Search Input --}}
<div class="flex-1 flex items-center min-w-0 px-1">
<template x-if="!isDefaultValue() && !open">
<span class="text-sm flex-1 truncate text-black dark:text-white px-2" x-text="getSelectedText()"></span>
</template>
<input type="text" x-show="isDefaultValue() || open" x-model="search" x-ref="searchInput"
@input="filterOptions()" @focus="open = true"
@keydown.escape="open = false"
:placeholder="{{ json_encode($placeholder ?: 'Search...') }}"
@readonly($readonly) @disabled($disabled) @if ($autofocus) autofocus @endif
class="flex-1 text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-black dark:text-white px-2" />
</div>
{{-- Dropdown Arrow --}}
<button type="button" @click.stop="open = !open" :disabled="{{ $disabled ? 'true' : 'false' }}"
class="shrink-0 text-neutral-400 px-2 {{ $disabled ? 'cursor-not-allowed' : 'cursor-pointer' }}">
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{{-- Dropdown Options --}}
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
<template x-if="filteredOptions.length === 0">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
No options found
</div>
</template>
<template x-for="option in filteredOptions" :key="option.value">
<div @click="selectOption(option.value)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selected == option.value }">
<span class="text-sm" x-text="option.text"></span>
</div>
</template>
</div>
{{-- Hidden datalist for options --}}
<datalist x-ref="datalist" style="display: none;">
{{ $slot }}
</datalist>
</div>
@endif
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
</div>
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
</div>

View file

@ -177,7 +177,7 @@ class="relative w-auto h-auto">
@endif
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen" x-cloak>
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs">
</div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
@ -186,8 +186,8 @@ class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300">
<div class="flex justify-between items-center pb-3">
class="relative w-full border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex justify-between items-center py-6 px-7 shrink-0">
<h3 class="pr-8 text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen = false; resetModal()"
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
@ -197,7 +197,7 @@ class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-f
</svg>
</button>
</div>
<div class="relative w-auto">
<div class="relative w-auto overflow-y-auto px-7 pb-6" style="-webkit-overflow-scrolling: touch;">
@if (!empty($checkboxes))
<!-- Step 1: Select actions -->
<div x-show="step === 1">

View file

@ -32,7 +32,7 @@ class="relative w-auto h-auto" wire:ignore>
<template x-teleport="body">
<div x-show="modalOpen"
x-init="$watch('modalOpen', value => { if(value) { $nextTick(() => { const firstInput = $el.querySelector('input, textarea, select'); firstInput?.focus(); }) } })"
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-4">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@ -45,8 +45,8 @@ class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w-[{{ $minWidth }}] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300">
<div class="flex items-center justify-between pb-3">
class="relative w-full border rounded-sm drop-shadow-sm min-w-full lg:min-w-[{{ $minWidth }}] max-w-fit max-h-[calc(100vh-2rem)] bg-white border-neutral-200 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex items-center justify-between py-6 px-6 shrink-0">
<h3 class="text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
@ -56,7 +56,7 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</svg>
</button>
</div>
<div class="relative flex items-center justify-center w-auto">
<div class="relative flex items-center justify-center w-auto overflow-y-auto px-6 pb-6" style="-webkit-overflow-scrolling: touch;">
{{ $slot }}
</div>
</div>

View file

@ -1,7 +1,7 @@
<dialog id="{{ $modalId }}" class="modal">
@if ($yesOrNo)
<form method="dialog" class="rounded-sm modal-box" @if (!$noSubmit) wire:submit='submit' @endif>
<div class="flex items-start">
<form method="dialog" class="rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col" @if (!$noSubmit) wire:submit='submit' @endif>
<div class="flex items-start overflow-y-auto" style="-webkit-overflow-scrolling: touch;">
<div class="flex items-center justify-center shrink-0 w-10 h-10 mr-4 rounded-full">
<svg class="w-8 h-8 text-error" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" aria-hidden="true">
@ -33,7 +33,8 @@
</div>
</form>
@else
<form method="dialog" class="flex flex-col w-11/12 max-w-5xl gap-2 rounded-sm modal-box"
<form method="dialog" class="flex flex-col w-11/12 max-w-5xl max-h-[calc(100vh-5rem)] gap-2 rounded-sm modal-box overflow-y-auto"
style="-webkit-overflow-scrolling: touch;"
@if ($submitWireAction) wire:submit={{ $submitWireAction }} @endif
@if (!$noSubmit && !$submitWireAction) wire:submit='submit' @endif>
@isset($modalTitle)

View file

@ -4,7 +4,9 @@
'hover:border-l-red-500 cursor-not-allowed' => $upgrade,
])>
<div class="flex items-center">
{{ $logo }}
<div class="w-[4.5rem] h-[4.5rem] flex items-center justify-center text-black dark:text-white shrink-0">
{{ $logo }}
</div>
<div class="flex flex-col pl-2 ">
<div class="dark:text-white text-md">
{{ $title }}

View file

@ -13,14 +13,14 @@
<x-status.stopped :status="$resource->status" />
@endif
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
<button wire:loading.remove title="Refresh Status" wire:click='checkStatus'
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading title="Refreshing Status" wire:click='checkStatus'
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path

View file

@ -10,14 +10,14 @@
<x-status.stopped :status="$complexStatus" />
@endif
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
<button wire:loading.remove title="Refresh Status" wire:click='checkStatus'
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading title="Refreshing Status" wire:click='checkStatus'
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path

View file

@ -1,11 +1,19 @@
<!DOCTYPE html>
<html data-theme="dark" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<script>
// Immediate theme application - runs before any rendering
(function() {
const t = localStorage.theme || 'dark';
const d = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList[d ? 'add' : 'remove']('dark');
})();
</script>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#101010" id="theme-color-meta" />
<meta name="color-scheme" content="dark light" />
<meta name="Description" content="Coolify: An open-source & self-hostable Heroku / Netlify / Vercel alternative" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="twitter:card" content="summary_large_image" />
@ -41,6 +49,12 @@
@endenv
<meta name="csrf-token" content="{{ csrf_token() }}">
@vite(['resources/js/app.js', 'resources/css/app.css'])
<script>
// Update theme-color meta tag (non-critical, can run async)
const t = localStorage.theme || 'dark';
const isDark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.getElementById('theme-color-meta')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
</script>
<style>
[x-cloak] {
display: none !important;
@ -108,7 +122,7 @@
}
});
}
// Existing link sanitization
if (node.nodeName === 'A' && node.hasAttribute('href')) {
const href = node.getAttribute('href') || '';
@ -123,20 +137,11 @@
return DOMPurify.sanitize(html, config);
};
// Initialize theme if not set
if (!('theme' in localStorage)) {
localStorage.theme = 'dark';
document.documentElement.classList.add('dark')
} else if (localStorage.theme === 'dark') {
document.documentElement.classList.add('dark')
} else if (localStorage.theme === 'light') {
document.documentElement.classList.remove('dark')
} else {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
let theme = localStorage.theme
let cpuColor = '#1e90ff'
let ramColor = '#00ced1'

View file

@ -191,7 +191,7 @@ class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:b
</div>
</div>
</x-slot:content>
<livewire:server.new.by-hetzner :private_keys="$this->privateKeys" :limit_reached="false" />
<livewire:server.new.by-hetzner :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
@endif
@endcan

View file

@ -869,6 +869,14 @@ class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
<p class="mt-2 text-xs text-neutral-400 dark:text-neutral-500">
💡 Tip: Search for service names like "wordpress", "postgres", or "redis"
</p>
<div class="mt-4">
<a href="{{ route('onboarding') }}" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-coollabs dark:bg-warning hover:bg-coollabs-100 dark:hover:bg-warning/90 rounded-lg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
View Onboarding Guide
</a>
</div>
</div>
</div>
</template>

View file

@ -90,12 +90,12 @@
@if ($application->build_pack !== 'dockercompose')
<div class="flex items-end gap-2">
@if ($application->settings->is_container_label_readonly_enabled == false)
<x-forms.input placeholder="https://coolify.io" wire:model="application.fqdn"
<x-forms.input placeholder="https://coolify.io" wire:model="fqdn"
label="Domains" readonly
helper="Readonly labels are disabled. You can set the domains in the labels section."
x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="https://coolify.io" wire:model="application.fqdn"
<x-forms.input placeholder="https://coolify.io" wire:model="fqdn"
label="Domains"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "
x-bind:disabled="!canUpdate" />

View file

@ -13,9 +13,55 @@
<div x-data="searchResources()">
@if ($current_step === 'type')
<div x-init="window.addEventListener('scroll', () => isSticky = window.pageYOffset > 100)" class="sticky z-10 top-10 py-2">
<input autocomplete="off" x-ref="searchInput" class="input-sticky"
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
@keydown.window.slash.prevent="$refs.searchInput.focus()">
<div class="flex gap-2 items-start">
<input autocomplete="off" x-ref="searchInput" class="input-sticky flex-1"
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
@keydown.window.slash.prevent="$refs.searchInput.focus()">
<!-- Category Filter Dropdown -->
<div class="relative" x-data="{ openCategoryDropdown: false, categorySearch: '' }" @click.outside="openCategoryDropdown = false">
<!-- Loading/Disabled State -->
<div x-show="loading || categories.length === 0"
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-neutral-100 dark:bg-coolgray-200 cursor-not-allowed whitespace-nowrap opacity-50">
<span class="text-sm text-neutral-400 dark:text-neutral-600">Filter by category</span>
<svg class="w-4 h-4 text-neutral-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<!-- Active State -->
<div x-show="!loading && categories.length > 0"
@click="openCategoryDropdown = !openCategoryDropdown; $nextTick(() => { if (openCategoryDropdown) $refs.categorySearchInput.focus() })"
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-pointer hover:ring-coolgray-400 transition-all whitespace-nowrap">
<span class="text-sm truncate" x-text="selectedCategory === '' ? 'Filter by category' : selectedCategory" :class="selectedCategory === '' ? 'text-neutral-400 dark:text-neutral-600' : 'capitalize text-black dark:text-white'"></span>
<svg class="w-4 h-4 transition-transform text-neutral-400 shrink-0" :class="{ 'rotate-180': openCategoryDropdown }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<!-- Dropdown Menu -->
<div x-show="openCategoryDropdown" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg overflow-hidden">
<div class="sticky top-0 p-2 bg-white dark:bg-coolgray-100 border-b border-neutral-300 dark:border-coolgray-400">
<input type="text" x-ref="categorySearchInput" x-model="categorySearch" placeholder="Search categories..."
class="w-full px-2 py-1 text-sm rounded border border-neutral-300 dark:border-coolgray-400 bg-white dark:bg-coolgray-200 focus:outline-none focus:ring-2 focus:ring-coolgray-400"
@click.stop>
</div>
<div class="max-h-60 overflow-auto scrollbar">
<div @click="selectedCategory = ''; categorySearch = ''; openCategoryDropdown = false"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === '' }">
<span class="text-sm">All Categories</span>
</div>
<template x-for="category in categories.filter(cat => categorySearch === '' || cat.toLowerCase().includes(categorySearch.toLowerCase()))" :key="category">
<div @click="selectedCategory = category; categorySearch = ''; openCategoryDropdown = false"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 capitalize"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === category }">
<span class="text-sm" x-text="category"></span>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<div x-show="loading">Loading...</div>
<div x-show="!loading" class="flex flex-col gap-4 py-4">
@ -28,13 +74,13 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-1">
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view>
<x-slot:title><span x-text="application.name"></span></x-slot>
<x-slot:description>
<span x-html="window.sanitizeHTML(application.description)"></span>
</x-slot>
<x-slot:logo>
<img class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10"
:src="application.logo">
</x-slot:logo>
<x-slot:description>
<span x-html="window.sanitizeHTML(application.description)"></span>
</x-slot>
<x-slot:logo>
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
:src="application.logo">
</x-slot:logo>
</x-resource-view>
</div>
</template>
@ -47,10 +93,10 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3">
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view>
<x-slot:title><span x-text="application.name"></span></x-slot>
<x-slot:description><span x-text="application.description"></span></x-slot>
<x-slot:logo> <img
class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10 "
:src="application.logo"></x-slot>
<x-slot:description><span x-text="application.description"></span></x-slot>
<x-slot:logo> <img
class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
:src="application.logo"></x-slot>
</x-resource-view>
</div>
</template>
@ -63,12 +109,12 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-2">
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view>
<x-slot:title><span x-text="database.name"></span></x-slot>
<x-slot:description><span x-text="database.description"></span></x-slot>
<x-slot:logo>
<span x-show="database.logo">
<span x-html="database.logo"></span>
</span>
</x-slot>
<x-slot:description><span x-text="database.description"></span></x-slot>
<x-slot:logo>
<span x-show="database.logo">
<span x-html="database.logo"></span>
</span>
</x-slot>
</x-resource-view>
</div>
</template>
@ -95,33 +141,33 @@ class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-2">
<template x-if="service.name">
<span x-text="service.name"></span>
</template>
</x-slot>
<x-slot:description>
<template x-if="service.slogan">
<span x-text="service.slogan"></span>
</template>
</x-slot>
<x-slot:logo>
<template x-if="service.logo">
<img class="w-[4.5rem] aspect-square h-[4.5rem] p-2 transition-all duration-200 dark:opacity-30 grayscale group-hover:grayscale-0 group-hover:opacity-100 dark:bg-white/10 bg-black/10"
:src='service.logo'
x-on:error.window="$event.target.src = service.logo_github_url"
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
x-on:error="$event.target.src = '/coolify-logo.svg'"
:data-fallback='service.logo_github_url' />
</template>
</x-slot:logo>
<x-slot:documentation>
<template x-if="service.documentation">
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-coolgray-200 hover:no-underline dark:group-hover:text-white text-neutral-600"
onclick="event.stopPropagation()" :href="service.documentation"
target="_blank">
Docs
</a>
</div>
</template>
</x-slot:documentation>
</x-slot>
<x-slot:description>
<template x-if="service.slogan">
<span x-text="service.slogan"></span>
</template>
</x-slot>
<x-slot:logo>
<template x-if="service.logo">
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
:src='service.logo'
x-on:error.window="$event.target.src = service.logo_github_url"
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
x-on:error="$event.target.src = '/coolify-logo.svg'"
:data-fallback='service.logo_github_url' />
</template>
</x-slot:logo>
<x-slot:documentation>
<template x-if="service.documentation">
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-coolgray-200 hover:no-underline dark:group-hover:text-white text-neutral-600"
onclick="event.stopPropagation()" :href="service.documentation"
target="_blank">
Docs
</a>
</div>
</template>
</x-slot:documentation>
</x-resource-view>
</div>
</template>
@ -140,6 +186,8 @@ function sortFn(a, b) {
function searchResources() {
return {
search: '',
selectedCategory: '',
categories: [],
loading: false,
isSticky: false,
selecting: false,
@ -156,11 +204,13 @@ function searchResources() {
this.loading = true;
const {
services,
categories,
gitBasedApplications,
dockerBasedApplications,
databases
} = await this.$wire.loadServices();
this.services = services;
this.categories = categories || [];
this.gitBasedApplications = gitBasedApplications;
this.dockerBasedApplications = dockerBasedApplications;
this.databases = databases;
@ -171,15 +221,30 @@ function searchResources() {
},
filterAndSort(items, isSort = true) {
const searchLower = this.search.trim().toLowerCase();
let filtered = Object.values(items);
if (searchLower === '') {
return isSort ? Object.values(items).sort(sortFn) : Object.values(items);
// Filter by category if selected
if (this.selectedCategory !== '') {
const selectedCategoryLower = this.selectedCategory.toLowerCase();
filtered = filtered.filter(item => {
if (!item.category) return false;
// Handle comma-separated categories
const categories = item.category.includes(',')
? item.category.split(',').map(c => c.trim().toLowerCase())
: [item.category.toLowerCase()];
return categories.includes(selectedCategoryLower);
});
}
const filtered = Object.values(items).filter(item => {
return (item.name?.toLowerCase().includes(searchLower) ||
item.description?.toLowerCase().includes(searchLower) ||
item.slogan?.toLowerCase().includes(searchLower))
})
// Filter by search term
if (searchLower !== '') {
filtered = filtered.filter(item => {
return (item.name?.toLowerCase().includes(searchLower) ||
item.description?.toLowerCase().includes(searchLower) ||
item.slogan?.toLowerCase().includes(searchLower))
});
}
return isSort ? filtered.sort(sortFn) : filtered;
},
get filteredGitBasedApplications() {
@ -236,14 +301,14 @@ function searchResources() {
{{ $server->name }}
</div>
<div class="box-description">
{{ $server->description }}</div>
{{ $server->description }}
</div>
</div>
</div>
@empty
<div>
<div>No validated & reachable servers found. <a class="underline dark:text-white"
href="/servers">
<div>No validated & reachable servers found. <a class="underline dark:text-white" href="/servers">
Go to servers page
</a></div>
</div>
@ -303,8 +368,7 @@ function searchResources() {
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-6000"
onclick="event.stopPropagation()" href="https://hub.docker.com/_/postgres/"
target="_blank">
onclick="event.stopPropagation()" href="https://hub.docker.com/_/postgres/" target="_blank">
Documentation
</a>
</div>
@ -322,8 +386,7 @@ function searchResources() {
<div class="flex-1"></div>
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-600"
onclick="event.stopPropagation()" href="https://github.com/supabase/postgres"
target="_blank">
onclick="event.stopPropagation()" href="https://github.com/supabase/postgres" target="_blank">
Documentation
</a>
</div>
@ -361,8 +424,7 @@ function searchResources() {
<div class="flex items-center px-2" title="Read the documentation.">
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-600"
onclick="event.stopPropagation()" href="https://github.com/pgvector/pgvector"
target="_blank">
onclick="event.stopPropagation()" href="https://github.com/pgvector/pgvector" target="_blank">
Documentation
</a>
</div>
@ -377,4 +439,4 @@ function searchResources() {
<x-forms.button type="submit">Add Database</x-forms.button>
</form>
@endif
</div>
</div>

View file

@ -1,23 +1,30 @@
<style>
.compose-editor-container .coolify-monaco-editor>div>div>div {
height: 512px !important;
min-height: 512px !important;
}
</style>
<div x-data="{ raw: true, showNormalTextarea: false }">
<div class="pb-4">Volume names are updated upon save. The service UUID will be added as a prefix to all volumes, to
prevent
name collision. <br>To see the actual volume names, check the Deployable Compose file, or go to Storage
menu.</div>
<div x-cloak x-show="raw" class="font-mono">
<div x-cloak x-show="showNormalTextarea">
<x-forms.textarea rows="20" id="dockerComposeRaw">
<div class="compose-editor-container">
<div x-cloak x-show="raw" class="font-mono">
<div x-cloak x-show="showNormalTextarea">
<x-forms.textarea rows="25" id="dockerComposeRaw">
</x-forms.textarea>
</div>
<div x-cloak x-show="!showNormalTextarea">
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" id="dockerComposeRaw">
</x-forms.textarea>
</div>
</div>
<div x-cloak x-show="raw === false" class="font-mono">
<x-forms.textarea rows="25" readonly id="dockerCompose">
</x-forms.textarea>
</div>
<div x-cloak x-show="!showNormalTextarea">
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" rows="20"
id="dockerComposeRaw">
</x-forms.textarea>
</div>
</div>
<div x-cloak x-show="raw === false" class="font-mono">
<x-forms.textarea rows="20" readonly id="dockerCompose">
</x-forms.textarea>
</div>
<div class="pt-2 flex gap-2">
<div class="flex flex-col gap-2">
@ -46,4 +53,4 @@
Save
</x-forms.button>
</div>
</div>
</div>

View file

@ -23,7 +23,7 @@
</div>
@can('update', $this->env)
<div class="flex flex-col w-full gap-3">
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
<div class="flex flex-wrap w-full items-center gap-4">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox instantSave id="is_buildtime"
@ -69,7 +69,7 @@
</div>
@else
<div class="flex flex-col w-full gap-3">
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
<div class="flex flex-wrap w-full items-center gap-4">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox disabled id="is_buildtime"
@ -145,7 +145,7 @@
@endcan
@can('update', $this->env)
<div class="flex flex-col w-full gap-3">
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
<div class="flex flex-wrap w-full items-center gap-4">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox instantSave id="is_buildtime"
@ -213,7 +213,7 @@
</div>
@else
<div class="flex flex-col w-full gap-3">
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
<div class="flex flex-wrap w-full items-center gap-4">
@if (!$is_redis_credential)
@if ($type === 'service')
<x-forms.checkbox disabled id="is_buildtime"

View file

@ -46,17 +46,19 @@
<x-loading wire:poll.2000ms='getLogs(true)' />
@endif
</div>
<form wire:submit='getLogs(true)' class="flex gap-2 items-end">
<div class="w-96">
<form wire:submit='getLogs(true)' class="flex flex-col gap-4">
<div class="w-full sm:w-96">
<x-forms.input label="Only Show Number of Lines" placeholder="100" type="number" required
id="numberOfLines" :readonly="$streamLogs"></x-forms.input>
</div>
<x-forms.button type="submit">Refresh</x-forms.button>
<x-forms.checkbox instantSave label="Stream Logs" id="streamLogs"></x-forms.checkbox>
<x-forms.checkbox instantSave label="Include Timestamps" id="showTimeStamps"></x-forms.checkbox>
<div class="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:gap-2 sm:items-center">
<x-forms.button type="submit">Refresh</x-forms.button>
<x-forms.checkbox instantSave label="Stream Logs" id="streamLogs"></x-forms.checkbox>
<x-forms.checkbox instantSave label="Include Timestamps" id="showTimeStamps"></x-forms.checkbox>
</div>
</form>
<div :class="fullscreen ? 'fullscreen' : 'relative w-full py-4 mx-auto'">
<div class="flex overflow-y-auto flex-col-reverse px-4 py-2 w-full bg-white dark:text-white dark:bg-coolgray-100 scrollbar dark:border-coolgray-300 border-neutral-200"
<div class="flex overflow-y-auto overflow-x-hidden flex-col-reverse px-4 py-2 w-full min-w-0 bg-white dark:text-white dark:bg-coolgray-100 scrollbar dark:border-coolgray-300 border-neutral-200"
:class="fullscreen ? '' : 'max-h-96 border border-solid rounded-sm'">
<div :class="fullscreen ? 'fixed top-4 right-4' : 'absolute top-6 right-0'">
<div class="flex justify-end gap-4" :class="fullscreen ? 'fixed' : ''"
@ -98,9 +100,9 @@
</div>
</div>
@if ($outputs)
<pre id="logs" class="font-mono whitespace-pre-wrap">{{ $outputs }}</pre>
<pre id="logs" class="font-mono whitespace-pre-wrap break-all max-w-full">{{ $outputs }}</pre>
@else
<pre id="logs" class="font-mono whitespace-pre-wrap">Refresh to get the logs...</pre>
<pre id="logs" class="font-mono whitespace-pre-wrap break-all max-w-full">Refresh to get the logs...</pre>
@endif
</div>
</div>

View file

@ -14,7 +14,7 @@
submitAction="saveCaCertificate" :actions="[
'This will overwrite the existing CA certificate at /data/coolify/ssl/coolify-ca.crt with your custom CA certificate.',
'This will regenerate all SSL certificates for databases on this server and it will sign them with your custom CA.',
'You must manually redeploy all your databases on this server so that they use the new SSL certificates singned with your new CA certificate.',
'You must manually redeploy all your databases on this server so that they use the new SSL certificates signed with your new CA certificate.',
'Because of caching, you probably also need to redeploy all your resources on this server that are using this CA certificate.',
]"
confirmationText="/data/coolify/ssl/coolify-ca.crt" shortConfirmationLabel="CA Certificate Path"
@ -24,7 +24,7 @@
submitAction="regenerateCaCertificate" :actions="[
'This will generate a new CA certificate at /data/coolify/ssl/coolify-ca.crt and replace the existing one.',
'This will regenerate all SSL certificates for databases on this server and it will sign them with the new CA certificate.',
'You must manually redeploy all your databases on this server so that they use the new SSL certificates singned with the new CA certificate.',
'You must manually redeploy all your databases on this server so that they use the new SSL certificates signed with the new CA certificate.',
'Because of caching, you probably also need to redeploy all your resources on this server that are using this CA certificate.',
]"
confirmationText="/data/coolify/ssl/coolify-ca.crt" shortConfirmationLabel="CA Certificate Path"

View file

@ -61,6 +61,7 @@
<div>
<x-forms.select label="Server Type" id="selected_server_type" wire:model.live="selected_server_type"
helper="Learn more about <a class='inline-block underline dark:text-white' href='https://www.hetzner.com/cloud/' target='_blank'>Hetzner server types</a>"
required :disabled="!$selected_location">
<option value="">
{{ $selected_location ? 'Select a server type...' : 'Select a location first' }}

View file

@ -49,21 +49,30 @@
localStorage.setItem('theme', userSettings);
const themeMetaTag = document.querySelector('meta[name=theme-color]');
let isDark = false;
if (userSettings === 'dark') {
document.documentElement.classList.add('dark');
themeMetaTag.setAttribute('content', this.darkColorContent);
this.theme = 'dark';
isDark = true;
} else if (userSettings === 'light') {
document.documentElement.classList.remove('dark');
themeMetaTag.setAttribute('content', this.whiteColorContent);
this.theme = 'light';
} else if (darkModePreference) {
isDark = false;
} else if (userSettings === 'system') {
this.theme = 'system';
document.documentElement.classList.add('dark');
} else if (!darkModePreference) {
this.theme = 'system';
document.documentElement.classList.remove('dark');
if (darkModePreference) {
document.documentElement.classList.add('dark');
isDark = true;
} else {
document.documentElement.classList.remove('dark');
isDark = false;
}
}
// Update theme-color meta tag
if (themeMetaTag) {
themeMetaTag.setAttribute('content', isDark ? '#101010' : '#ffffff');
}
},
mounted() {

View file

@ -1,77 +1,93 @@
<div>
<x-slot:title>
Advanced Settings | Coolify
</x-slot>
<x-settings.navbar />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
<x-settings.sidebar activeMenu="advanced" />
<form wire:submit='submit' class="flex flex-col">
<div class="flex items-center gap-2">
<h2>Advanced</h2>
<x-forms.button type="submit">
Save
</x-forms.button>
</div>
<div class="pb-4">Advanced settings for your Coolify instance.</div>
<div class="flex flex-col gap-1 md:w-96">
<x-forms.checkbox instantSave id="is_registration_enabled"
helper="If enabled, users can register themselves. If disabled, only administrators can create new users."
label="Registration Allowed" />
<x-forms.checkbox instantSave id="do_not_track"
helper="If enabled, Coolify will not track any data. This is useful if you are concerned about privacy."
label="Do Not Track" />
<h4 class="pt-4">DNS Settings</h4>
<x-forms.checkbox instantSave id="is_dns_validation_enabled"
helper="If you set a custom domain, Coolify will validate the domain in your DNS provider."
label="DNS Validation" />
<x-forms.input id="custom_dns_servers" label="Custom DNS Servers"
helper="DNS servers to validate domains against. A comma separated list of DNS servers."
placeholder="1.1.1.1,8.8.8.8" />
<h4 class="pt-4">API Settings</h4>
<x-forms.checkbox instantSave id="is_api_enabled" label="API Access"
helper="If enabled, the API will be enabled. If disabled, the API will be disabled." />
<x-forms.input id="allowed_ips" label="Allowed IPs for API Access"
helper="Allowed IP addresses or subnets for API access.<br>Supports single IPs (192.168.1.100) and CIDR notation (192.168.1.0/24).<br>Use comma to separate multiple entries.<br>Use 0.0.0.0 or leave empty to allow from anywhere."
placeholder="192.168.1.100,10.0.0.0/8,203.0.113.0/24" />
<h4 class="pt-4">Confirmation Settings</h4>
<div class="md:w-96 pb-1">
<x-forms.checkbox instantSave id="is_sponsorship_popup_enabled" label="Show Sponsorship Popup"
helper="When enabled, sponsorship popups will be shown monthly to users. When disabled, the sponsorship popup will be permanently hidden for all users." />
</x-slot>
<x-settings.navbar />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }"
class="flex flex-col h-full gap-8 sm:flex-row">
<x-settings.sidebar activeMenu="advanced" />
<form wire:submit='submit' class="flex flex-col w-full">
<div class="flex items-center gap-2">
<h2>Advanced</h2>
<x-forms.button type="submit">
Save
</x-forms.button>
</div>
</div>
<div class="flex flex-col gap-1">
@if ($disable_two_step_confirmation)
<div class="md:w-96 pb-4" wire:key="two-step-confirmation-enabled">
<x-forms.checkbox instantSave id="disable_two_step_confirmation"
label="Disable Two Step Confirmation"
helper="When disabled, you will not need to confirm actions with a text and user password. This significantly reduces security and may lead to accidental deletions or unwanted changes. Use with extreme caution, especially on production servers." />
<div class="pb-4">Advanced settings for your Coolify instance.</div>
<div class="flex flex-col gap-1">
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_registration_enabled"
helper="If enabled, users can register themselves. If disabled, only administrators can create new users."
label="Registration Allowed" />
</div>
@else
<div class="md:w-96 pb-4 flex items-center justify-between gap-2"
wire:key="two-step-confirmation-disabled">
<label class="flex items-center gap-2">
Disable Two Step Confirmation
<x-helper
helper="When disabled, you will not need to confirm actions with a text and user password. This significantly reduces security and may lead to accidental deletions or unwanted changes. Use with extreme caution, especially on production servers.">
</x-helper>
</label>
<x-modal-confirmation title="Disable Two Step Confirmation?" buttonTitle="Disable" isErrorButton
submitAction="toggleTwoStepConfirmation" :actions="[
'Two Step confirmation will be disabled globally.',
'Disabling two step confirmation reduces security (as anyone can easily delete anything).',
'The risk of accidental actions will increase.',
]"
confirmationText="DISABLE TWO STEP CONFIRMATION"
confirmationLabel="Please type the confirmation text to disable two step confirmation."
shortConfirmationLabel="Confirmation text" />
<div class="md:w-96">
<x-forms.checkbox instantSave id="do_not_track"
helper="If enabled, Coolify will not track any data. This is useful if you are concerned about privacy."
label="Do Not Track" />
</div>
<x-callout type="danger" title="Warning!" class="mb-4">
Disabling two step confirmation reduces security (as anyone can easily delete anything) and
increases the risk of accidental actions. This is not recommended for production servers.
</x-callout>
@endif
</div>
</form>
</div>
</div>
<h4 class="pt-4">DNS Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_dns_validation_enabled"
helper="If you set a custom domain, Coolify will validate the domain in your DNS provider."
label="DNS Validation" />
</div>
<x-forms.input id="custom_dns_servers" label="Custom DNS Servers"
helper="DNS servers to validate domains against. A comma separated list of DNS servers."
placeholder="1.1.1.1,8.8.8.8" />
<h4 class="pt-4">API Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_api_enabled" label="API Access"
helper="If enabled, the API will be enabled. If disabled, the API will be disabled." />
</div>
<x-forms.input id="allowed_ips" label="Allowed IPs for API Access"
helper="Allowed IP addresses or subnets for API access.<br>Supports single IPs (192.168.1.100) and CIDR notation (192.168.1.0/24).<br>Use comma to separate multiple entries.<br>Use 0.0.0.0 or leave empty to allow from anywhere."
placeholder="192.168.1.100,10.0.0.0/8,203.0.113.0/24" />
@if (empty($allowed_ips) || in_array('0.0.0.0', array_map('trim', explode(',', $allowed_ips ?? ''))))
<x-callout type="warning" title="Warning" class="mt-2">
Using 0.0.0.0 (or empty) allows API access from anywhere. This is not recommended for production
environments!
</x-callout>
@endif
<h4 class="pt-4">Confirmation Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id=" is_sponsorship_popup_enabled" label="Show Sponsorship Popup"
helper="When enabled, sponsorship popups will be shown monthly to users. When disabled, the sponsorship popup will be permanently hidden for all users." />
</div>
</div>
<div class="flex flex-col gap-1">
@if ($disable_two_step_confirmation)
<div class="pb-4 md:w-96" wire:key="two-step-confirmation-enabled">
<x-forms.checkbox instantSave id="disable_two_step_confirmation"
label="Disable Two Step Confirmation"
helper="When disabled, you will not need to confirm actions with a text and user password. This significantly reduces security and may lead to accidental deletions or unwanted changes. Use with extreme caution, especially on production servers." />
</div>
@else
<div class="pb-4 flex items-center justify-between gap-2 md:w-96"
wire:key="two-step-confirmation-disabled">
<label class="flex items-center gap-2">
Disable Two Step Confirmation
<x-helper
helper="When disabled, you will not need to confirm actions with a text and user password. This significantly reduces security and may lead to accidental deletions or unwanted changes. Use with extreme caution, especially on production servers.">
</x-helper>
</label>
<x-modal-confirmation title="Disable Two Step Confirmation?" buttonTitle="Disable" isErrorButton
submitAction="toggleTwoStepConfirmation" :actions="[
'Two Step confirmation will be disabled globally.',
'Disabling two step confirmation reduces security (as anyone can easily delete anything).',
'The risk of accidental actions will increase.',
]"
confirmationText="DISABLE TWO STEP CONFIRMATION"
confirmationLabel="Please type the confirmation text to disable two step confirmation."
shortConfirmationLabel="Confirmation text" />
</div>
<x-callout type="danger" title="Warning!" class="mb-4">
Disabling two step confirmation reduces security (as anyone can easily delete anything) and
increases the risk of accidental actions. This is not recommended for production servers.
</x-callout>
@endif
</div>
</form>
</div>
</div>

View file

@ -1,7 +1,7 @@
<div>
@if (data_get($github_app, 'app_id'))
<form wire:submit='submit'>
<div class="flex items-center gap-2">
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitHub App</h1>
<div class="flex gap-2">
@if (data_get($github_app, 'installation_id'))
@ -40,8 +40,8 @@
</a>
@else
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<div class="flex items-end gap-2 w-full">
<div class="flex flex-col sm:flex-row gap-2">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2 w-full">
<x-forms.input canGate="update" :canResource="$github_app" id="name" label="App Name" />
<x-forms.button canGate="update" :canResource="$github_app" wire:click.prevent="updateGithubAppName">
Sync Name
@ -72,24 +72,29 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
helper="If checked, this GitHub App will be available for everyone in this Coolify instance."
instantSave id="isSystemWide" />
</div>
@if ($isSystemWide)
<x-callout type="warning" title="Not Recommended">
System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team can use this GitHub App to deploy applications from your repositories. For better security and isolation, it's recommended to create team-specific GitHub Apps instead.
</x-callout>
@endif
@endif
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="htmlUrl" label="HTML Url" />
<x-forms.input canGate="update" :canResource="$github_app" id="apiUrl" label="API Url" />
</div>
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="customUser" label="User"
required />
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="customPort"
label="Port" required />
</div>
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="appId"
label="App Id" required />
<x-forms.input canGate="update" :canResource="$github_app" type="number"
id="installationId" label="Installation Id" required />
</div>
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="clientId" label="Client Id"
type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="clientSecret"
@ -108,7 +113,7 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
@endforeach
</x-forms.select>
</div>
<div class="flex items-end gap-2 ">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<h2 class="pt-4">Permissions</h2>
@can('view', $github_app)
<x-forms.button wire:click.prevent="checkPermissions">Refetch</x-forms.button>
@ -120,7 +125,7 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
</a>
@endcan
</div>
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<x-forms.input id="contents" helper="read - mandatory." label="Content" readonly
placeholder="N/A" />
<x-forms.input id="metadata" helper="read - mandatory." label="Metadata" readonly
@ -144,56 +149,61 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
</div>
<div class="pb-4 title">Here you can find all resources that are using this source.</div>
</div>
<div class="flex flex-col">
@if ($applications->isEmpty())
<div class="py-4 text-sm opacity-70">
No resources are currently using this GitHub App.
</div>
@else
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Project
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Environment</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type
</th>
</tr>
</thead>
<tbody class="divide-y">
@forelse ($applications->sortBy('name',SORT_NATURAL) as $resource)
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource->project(), 'name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource, 'environment.name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap"><a
class=""
href="{{ $resource->link() }}">{{ $resource->name }}
<x-internal-link /></a>
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ str($resource->type())->headline() }}</td>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Project
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">
Environment</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Name
</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Type
</th>
</tr>
@empty
@endforelse
</tbody>
</table>
</thead>
<tbody class="divide-y">
@foreach ($applications->sortBy('name',SORT_NATURAL) as $resource)
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource->project(), 'name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource, 'environment.name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap"><a
class=""
href="{{ $resource->link() }}">{{ $resource->name }}
<x-internal-link /></a>
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ str($resource->type())->headline() }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
@endif
</div>
</div>
@endif
@else
<div class="flex items-center gap-2 pb-4">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 pb-4">
<h1>GitHub App</h1>
<div class="flex gap-2">
@can('delete', $github_app)
@ -228,7 +238,7 @@ class=""
<div class="pb-10">
@can('create', $github_app)
@if (!isCloud() || isDev())
<div class="flex items-end gap-2">
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<x-forms.select wire:model.live='webhook_endpoint' label="Webhook Endpoint"
helper="All Git webhooks will be sent to this endpoint. <br><br>If you would like to use domain instead of IP address, set your Coolify instance's FQDN in the Settings menu.">
@if ($ipv4)
@ -250,7 +260,7 @@ class=""
</x-forms.button>
</div>
@else
<div class="flex gap-2">
<div class="flex flex-col sm:flex-row gap-2">
<h2>Register a GitHub App</h2>
<x-forms.button isHighlighted
x-on:click.prevent="createGithubApp('{{ $webhook_endpoint }}','{{ $preview_deployment_permissions }}',{{ $administration }})">
@ -261,11 +271,11 @@ class=""
@endif
<div class="flex flex-col gap-2 pt-4 w-96">
<x-forms.checkbox disabled instantSave id="default_permissions" label="Mandatory"
<x-forms.checkbox disabled id="default_permissions" label="Mandatory"
helper="Contents: read<br>Metadata: read<br>Email: read" />
<x-forms.checkbox instantSave id="preview_deployment_permissions" label="Preview Deployments "
<x-forms.checkbox id="preview_deployment_permissions" label="Preview Deployments "
helper="Necessary for updating pull requests with useful comments (deployment status, links, etc.)<br><br>Pull Request: read & write" />
{{-- <x-forms.checkbox instantSave id="administration" label="Administration (for Github Runners)"
{{-- <x-forms.checkbox id="administration" label="Administration (for Github Runners)"
helper="Necessary for adding Github Runners to repositories.<br><br>Administration: read & write" /> --}}
</div>
@else

View file

@ -9,17 +9,28 @@
placeholder="If empty, your GitHub user will be used." id="organization" label="Organization (on GitHub)" />
</div>
@if (!isCloud())
<div class="w-48">
<x-forms.checkbox id="is_system_wide" label="System Wide"
helper="If checked, this GitHub App will be available for everyone in this Coolify instance." />
<div x-data="{ showWarning: @entangle('is_system_wide') }">
<div class="w-48">
<x-forms.checkbox id="is_system_wide" label="System Wide"
helper="If checked, this GitHub App will be available for everyone in this Coolify instance." />
</div>
<div x-show="showWarning" x-transition x-cloak class="w-full max-w-2xl mx-auto pt-2">
<x-callout type="warning" title="Not Recommended">
<div class="whitespace-normal break-words">
System-wide GitHub Apps are shared across all teams on this Coolify instance. This means any team
can use this GitHub App to deploy applications from your repositories. For better security and
isolation, it's recommended to create team-specific GitHub Apps instead.
</div>
</x-callout>
</div>
</div>
@endif
<div x-data="{
activeAccordion: '',
setActiveAccordion(id) {
this.activeAccordion = (this.activeAccordion == id) ? '' : id
}
}" class="relative w-full py-2 mx-auto overflow-hidden text-sm font-normal rounded-md">
activeAccordion: '',
setActiveAccordion(id) {
this.activeAccordion = (this.activeAccordion == id) ? '' : id
}
}" class="relative w-full py-2 mx-auto overflow-hidden text-sm font-normal rounded-md">
<div x-data="{ id: $id('accordion') }" class="cursor-pointer">
<button @click="setActiveAccordion(id)"
class="flex items-center justify-between w-full px-1 py-2 text-left select-none dark:hover:text-white hover:bg-white/5"
@ -55,4 +66,4 @@ class="flex items-center justify-between w-full px-1 py-2 text-left select-none
<x-callout type="warning" title="Permission Required">
You don't have permission to create new GitHub Apps. Please contact your team administrator for access.
</x-callout>
@endcan
@endcan

View file

@ -22,7 +22,7 @@ services:
retries: 10
minio:
image: minio/minio:latest
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
command: server /data --console-address ":9001"
environment:
- MINIO_SERVER_URL=$MINIO_SERVER_URL
@ -32,7 +32,7 @@ services:
volumes:
- azimutt-minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 20s
retries: 10

View file

@ -66,7 +66,7 @@ services:
start_period: 10s
minio-service:
image: minio/minio
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
volumes:
- minio_data:/data
environment:
@ -75,10 +75,10 @@ services:
- MINIO_BROWSER=off
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 20s
retries: 3
retries: 10
proxy-service:
image: budibase/proxy

View file

@ -86,7 +86,7 @@ services:
retries: 5
minio:
image: 'quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z' # Released at 2025-09-07T16-13-09Z
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
command: 'server /data --console-address ":9001"'
environment:
- MINIO_SERVER_URL=$MINIO_SERVER_URL
@ -96,11 +96,7 @@ services:
volumes:
- 'minio-data:/data'
healthcheck:
test:
- CMD
- mc
- ready
- local
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 20s
retries: 10

View file

@ -0,0 +1,40 @@
# documentation: https://www.home-assistant.io/installation/linux#docker-compose
# slogan: Open source home automation that puts local control and privacy first.
# category: automation
# tags: home-automation,iot,smart-home,automation,domotics,mqtt,zigbee,zwave
# logo: svgs/home-assistant.svg
# port: 8123
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:2025.10.2
environment:
- SERVICE_URL_HOMEASSISTANT_8123
- TZ=${TZ:-UTC}
- DISABLE_JEMALLOC=${DISABLE_JEMALLOC:-false}
volumes:
- homeassistant-config:/config
- /run/dbus:/run/dbus:ro
- type: bind
source: ./configuration.yaml
target: /config/configuration.yaml
content: |
# Loads default set of integrations. Do not remove.
default_config:
# Configuration for reverse proxy support (required for Coolify)
http:
use_x_forwarded_for: true
trusted_proxies:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ip_ban_enabled: true
login_attempts_threshold: 5
privileged: true
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8123"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s

View file

@ -15,10 +15,15 @@ services:
volumes:
- huly-db:/data/db
minio:
image: "minio/minio"
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
command: server /data --address ":9000" --console-address ":9001"
volumes:
- huly-files:/data
- huly-files:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 20s
retries: 10
elastic:
image: "elasticsearch:7.14.2"
command: |

View file

@ -0,0 +1,50 @@
# documentation: https://github.com/metatool-ai/metamcp
# slogan: MCP Aggregator, Orchestrator, Middleware, Gateway in one app
# tags: mcp, ai, sse, aggregator, orchestrator, middleware
# category: mcp
# logo: svgs/metamcp.png
# port: 12008
services:
app:
image: ghcr.io/metatool-ai/metamcp:2.4
environment:
- SERVICE_URL_METAMCP_12008
- POSTGRES_HOST=${POSTGRES_HOST:-postgres}
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
- POSTGRES_USER=${SERVICE_USER_POSTGRES}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- POSTGRES_DB=${POSTGRES_DB:-metamcp_db}
- DATABASE_URL=postgresql://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-metamcp_db}
- APP_URL=${SERVICE_URL_METAMCP}
- NEXT_PUBLIC_APP_URL=${SERVICE_URL_METAMCP}
- BETTER_AUTH_SECRET=${SERVICE_PASSWORD_AUTH}
- TRANSFORM_LOCALHOST_TO_DOCKER_INTERNAL=${TRANSFORM_LOCALHOST_TO_DOCKER_INTERNAL:-true}
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
- CMD
- curl
- '-f'
- 'http://localhost:12008/health'
interval: 10s
timeout: 5s
retries: 5
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_DB=${POSTGRES_DB:-metamcp_db}
- POSTGRES_USER=${SERVICE_USER_POSTGRES}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test:
- CMD-SHELL
- 'pg_isready -U ${SERVICE_USER_POSTGRES} -d ${POSTGRES_DB:-metamcp_db}'
interval: 10s
timeout: 5s
retries: 5

View file

@ -0,0 +1,22 @@
# documentation: https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images
# slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs.
# category: storage
# tags: object, storage, server, s3, api
# logo: svgs/minio.svg
services:
minio:
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
command: server /data --console-address ":9001"
environment:
- MINIO_SERVER_URL=$MINIO_SERVER_URL
- MINIO_BROWSER_REDIRECT_URL=$MINIO_BROWSER_REDIRECT_URL
- MINIO_ROOT_USER=$SERVICE_USER_MINIO
- MINIO_ROOT_PASSWORD=$SERVICE_PASSWORD_MINIO
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 20s
retries: 10

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://min.io/docs/minio/container/index.html
# slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs.
# category: storage

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://github.com/stonith404/pingvin-share
# slogan: A self-hosted file sharing platform that combines lightness and beauty, perfect for seamless and efficient file sharing.
# category: storage

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://github.com/stonith404/pingvin-share
# slogan: A self-hosted file sharing platform that combines lightness and beauty, perfect for seamless and efficient file sharing.
# category: storage

View file

@ -211,7 +211,7 @@ services:
plane-minio:
<<: *app-env
image: minio/minio:latest
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
command: server /export --console-address ":9090"
volumes:
- uploads:/export

View file

@ -0,0 +1,55 @@
# documentation: https://pocket-id.org/docs/setup/installation
# slogan: A simple and secure OIDC provider with passkey authentication
# category: auth
# tags: identity,oidc,oauth,passkey,webauthn,authentication,sso,openid,postgresql
# logo: svgs/pocketid-logo.png
# port: 1411
services:
pocket-id:
image: ghcr.io/pocket-id/pocket-id:v1.13
environment:
- SERVICE_URL_POCKETID_1411
- APP_URL=${SERVICE_URL_POCKETID}
- TRUST_PROXY=${TRUST_PROXY:-true}
- DB_PROVIDER=postgres
- DB_CONNECTION_STRING=postgresql://${SERVICE_USER_POSTGRESQL}:${SERVICE_PASSWORD_POSTGRESQL}@postgresql:5432/${POSTGRES_DB:-pocketid}
- ENCRYPTION_KEY=${SERVICE_PASSWORD_64_POCKETID}
- KEYS_STORAGE=${KEYS_STORAGE:-database}
- MAXMIND_LICENSE_KEY=${MAXMIND_LICENSE_KEY}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_FROM=${SMTP_FROM}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_TLS=${SMTP_TLS:-starttls}
- SMTP_SKIP_CERT_VERIFY=${SMTP_SKIP_CERT_VERIFY:-false}
- EMAIL_LOGIN_NOTIFICATION_ENABLED=${EMAIL_LOGIN_NOTIFICATION_ENABLED:-false}
- EMAIL_ONE_TIME_ACCESS_AS_ADMIN_ENABLED=${EMAIL_ONE_TIME_ACCESS_AS_ADMIN_ENABLED:-false}
- EMAIL_API_KEY_EXPIRATION_ENABLED=${EMAIL_API_KEY_EXPIRATION_ENABLED:-false}
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
volumes:
- pocket-id-data:/app/data
healthcheck:
test: ["CMD", "/app/pocket-id", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
postgresql:
image: postgres:16-alpine
volumes:
- pocket-id-postgresql-data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
- POSTGRES_DB=${POSTGRES_DB:-pocketid}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 20s
retries: 10

View file

@ -0,0 +1,35 @@
# documentation: https://pocket-id.org/docs/setup/installation
# slogan: A simple and secure OIDC provider with passkey authentication
# category: auth
# tags: identity,oidc,oauth,passkey,webauthn,authentication,sso,openid
# logo: svgs/pocketid-logo.png
# port: 1411
services:
pocket-id:
image: ghcr.io/pocket-id/pocket-id:v1.13
environment:
- SERVICE_URL_POCKETID_1411
- APP_URL=${SERVICE_URL_POCKETID}
- TRUST_PROXY=${TRUST_PROXY:-true}
- MAXMIND_LICENSE_KEY=${MAXMIND_LICENSE_KEY}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_FROM=${SMTP_FROM}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_TLS=${SMTP_TLS:-starttls}
- SMTP_SKIP_CERT_VERIFY=${SMTP_SKIP_CERT_VERIFY:-false}
- EMAIL_LOGIN_NOTIFICATION_ENABLED=${EMAIL_LOGIN_NOTIFICATION_ENABLED:-false}
- EMAIL_ONE_TIME_ACCESS_AS_ADMIN_ENABLED=${EMAIL_ONE_TIME_ACCESS_AS_ADMIN_ENABLED:-false}
- EMAIL_API_KEY_EXPIRATION_ENABLED=${EMAIL_API_KEY_EXPIRATION_ENABLED:-false}
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
volumes:
- pocket-id-data:/app/data
healthcheck:
test: ["CMD", "/app/pocket-id", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

View file

@ -1870,14 +1870,19 @@ services:
- ALLOW_PLAINTEXT_LISTENER=yes
object_storage:
image: minio/minio:RELEASE.2022-06-25T15-50-16Z
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
environment:
- MINIO_ROOT_USER=$SERVICE_USER_MINIO
- MINIO_ROOT_PASSWORD=$SERVICE_PASSWORD_MINIO
entrypoint: sh
command: -c 'mkdir -p /data/posthog && minio server --address ":19000" --console-address ":19001" /data'
command: -c 'mkdir -p /data/posthog && minio server --address ":9000" --console-address ":9001" /data'
volumes:
- object_storage:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 20s
retries: 10
maildev:
image: maildev/maildev:2.0.5

View file

@ -46,7 +46,7 @@ services:
retries: 10
minio:
image: quay.io/minio/minio:latest
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
command: server /data --console-address ":9001"
environment:
- MINIO_SERVER_URL=$MINIO_SERVER_URL

View file

@ -0,0 +1,31 @@
# documentation: https://redis.io/docs/latest/operate/redisinsight/
# slogan: Redis Insight lets you do both GUI- and CLI-based interactions in a fully-featured desktop GUI client.
# category: database,observability,developer-tools
# tags: redis,gui,database,monitoring,analytics
# logo: svgs/redisinsight.png
# port: 5540
services:
redisinsight:
image: 'redis/redisinsight:2.70' # Released on Jul 11, 2025
environment:
- SERVICE_URL_REDISINSIGHT_5540
- RI_APP_HOST=0.0.0.0
- RI_APP_PORT=5540
- 'RI_ENCRYPTION_KEY=${SERVICE_PASSWORD_RI_ENCRYPTION_KEY}'
- 'RI_LOG_LEVEL=${RI_LOG_LEVEL:-info}'
- 'RI_FILES_LOGGER=${RI_FILES_LOGGER:-true}'
- 'RI_STDOUT_LOGGER=${RI_STDOUT_LOGGER:-true}'
volumes:
- 'redis_insight_data:/data'
healthcheck:
test:
- CMD
- wget
- '--spider'
- 'http://localhost:5540'
interval: 10s
retries: 3
timeout: 10s
start_period: 10s

View file

@ -0,0 +1,42 @@
# documentation: https://www.rivet.dev/docs
# slogan: Build and scale stateful workloads with long-lived processes
# category: development
# tags: stateful, actors, realtime, backend, serverless, postgresql
# logo: svgs/rivet.svg
# port: 6420
services:
rivet-engine:
image: rivetkit/engine:25.8.0
environment:
- SERVICE_URL_RIVET_6420
- 'RIVET__AUTH__ADMIN_TOKEN=${SERVICE_PASSWORD_RIVET}'
- RIVET__POSTGRES__URL=postgresql://$SERVICE_USER_POSTGRESQL:$SERVICE_PASSWORD_POSTGRESQL@postgresql:5432/${POSTGRESQL_DATABASE-rivet}
depends_on:
postgresql:
condition: service_healthy
healthcheck:
test:
- CMD
- curl
- '-f'
- http://127.0.0.1:6420/health
interval: 2s
timeout: 10s
retries: 10
start_period: 30s
postgresql:
image: postgres:17-alpine
volumes:
- rivet-postgresql-data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
- POSTGRES_DB=${POSTGRESQL_DATABASE-rivet}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 20s
retries: 10

View file

@ -0,0 +1,29 @@
# documentation: https://github.com/siyuan-note/siyuan
# slogan: A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang.
# tags: note-taking,markdown,pkm
# category: documentation
# logo: svgs/siyuan.svg
# port: 6806
services:
siyuan:
image: b3log/siyuan:v3.3.5
volumes:
- 'siyuan_workspace:/siyuan/workspace'
environment:
- SERVICE_URL_SIYUAN_6806
- TZ=${TZ:-UTC}
- PUID=1000
- PGID=1000
- 'SIYUAN_ACCESS_AUTH_CODE=${SERVICE_PASSWORD_SIYUAN}'
healthcheck:
test:
- CMD
- wget
- '--spider'
- '--quiet'
- 'http://127.0.0.1:6806/api/system/version'
interval: 15s
timeout: 10s
retries: 5
start_period: 20s

View file

@ -0,0 +1,56 @@
# documentation: https://codewithcj.github.io/SparkyFitness/
# slogan: SparkyFitness is a comprehensive fitness tracking and management application designed to help users monitor their nutrition, exercise, and body measurements. It provides tools for daily progress tracking, goal setting, and insightful reports to support a healthy lifestyle.
# category: health
# tags: sparkyfitness, fitness, health, nutrition, exercise, body measurements
# logo: svgs/sparkyfitness.svg
# port: 80
services:
sparkyfitness-frontend:
image: 'codewithcj/sparkyfitness:v0.15.7.3' # Released on Oct 18, 2025
environment:
- SERVICE_URL_SPARKYFITNESS_80
depends_on:
- sparkyfitness-server
sparkyfitness-server:
image: 'codewithcj/sparkyfitness_server:v0.15.7.3' # Released on Oct 18, 2025
environment:
- 'SPARKY_FITNESS_LOG_LEVEL=${SPARKY_FITNESS_LOG_LEVEL:-info}'
- 'SPARKY_FITNESS_DB_USER=${SERVICE_USER_POSTGRES}'
- SPARKY_FITNESS_DB_HOST=sparkyfitness-db
- 'SPARKY_FITNESS_DB_NAME=${SPARKY_FITNESS_DB_NAME:-sparkyfitness}'
- 'SPARKY_FITNESS_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
- 'SPARKY_FITNESS_DB_PORT=${SPARKY_FITNESS_DB_PORT:-5432}'
- 'SPARKY_FITNESS_API_ENCRYPTION_KEY=${SERVICE_PASSWORD_64_SERVERAPIENCRYPTIONKEY}'
- 'JWT_SECRET=${SERVICE_PASSWORD_64_SERVERJWTSECRET}'
- 'SPARKY_FITNESS_FRONTEND_URL=${SERVICE_URL_SPARKYFITNESS_80}'
- 'SPARKY_FITNESS_DISABLE_SIGNUP=${SPARKY_FITNESS_DISABLE_SIGNUP:-false}'
- 'SPARKY_FITNESS_ADMIN_EMAIL=${SPARKY_FITNESS_ADMIN_EMAIL:-admin@example.com}'
- 'SPARKY_FITNESS_EMAIL_HOST=${SPARKY_FITNESS_EMAIL_HOST:-smtp.gmail.com}'
- 'SPARKY_FITNESS_EMAIL_PORT=${SPARKY_FITNESS_EMAIL_PORT:-587}'
- 'SPARKY_FITNESS_EMAIL_SECURE=${SPARKY_FITNESS_EMAIL_SECURE:-false}'
- 'SPARKY_FITNESS_EMAIL_USER=${SPARKY_FITNESS_EMAIL_USER}'
- 'SPARKY_FITNESS_EMAIL_PASS=${SPARKY_FITNESS_EMAIL_PASS}'
- 'SPARKY_FITNESS_EMAIL_FROM=${SPARKY_FITNESS_EMAIL_FROM:-"Sparky Fitness <noreply@sparkyfitness.com>"}'
depends_on:
- sparkyfitness-db
volumes:
- 'sparkyfitness-server-backup:/app/SparkyFitnessServer/backup'
- 'sparkyfitness-server-uploads:/app/SparkyFitnessServer/uploads'
sparkyfitness-db:
image: 'postgres:15-alpine'
environment:
- 'POSTGRES_DB=${SPARKY_FITNESS_DB_NAME:-sparkyfitness}'
- 'POSTGRES_USER=${SERVICE_USER_POSTGRES}'
- 'POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
- POSTGRES_PORT=${SPARKY_FITNESS_DB_PORT:-5432}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 20s
retries: 10
volumes:
- 'sparkyfitness-db-postgresql:/var/lib/postgresql/data'

View file

@ -1072,16 +1072,16 @@ services:
command: >
sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server"
supabase-minio:
image: minio/minio
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
environment:
- MINIO_ROOT_USER=${SERVICE_USER_MINIO}
- MINIO_ROOT_PASSWORD=${SERVICE_PASSWORD_MINIO}
command: server --console-address ":9001" /data
healthcheck:
test: sleep 5 && exit 0
interval: 2s
timeout: 10s
retries: 5
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 20s
retries: 10
volumes:
- ./volumes/storage:/data

View file

@ -1,6 +1,6 @@
# documentation: https://github.com/wg-easy/wg-easy
# slogan: The easiest way to run WireGuard VPN + Web-based Admin UI.
# category: vps
# category: vpn
# tags: wireguard,vpn,web,admin
# logo: svgs/wireguard.svg
# port: 8000

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,208 @@
<?php
use App\Livewire\Source\Github\Change;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Set current team
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('GitHub Source Change Component', function () {
test('can mount with newly created github app with null app_id', function () {
// Create a GitHub app without app_id (simulating a newly created source)
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
// app_id is intentionally not set (null in database)
]);
// Test that the component can mount without errors
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('appId', null)
->assertSet('installationId', null)
->assertSet('clientId', null)
->assertSet('clientSecret', null)
->assertSet('webhookSecret', null)
->assertSet('privateKeyId', null);
});
test('can mount with fully configured github app', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('appId', 12345)
->assertSet('installationId', 67890)
->assertSet('clientId', 'test-client-id')
->assertSet('clientSecret', 'test-client-secret')
->assertSet('webhookSecret', 'test-webhook-secret')
->assertSet('privateKeyId', $privateKey->id);
});
test('can update github app from null to valid values', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->set('appId', 12345)
->set('installationId', 67890)
->set('clientId', 'new-client-id')
->set('clientSecret', 'new-client-secret')
->set('webhookSecret', 'new-webhook-secret')
->set('privateKeyId', $privateKey->id)
->call('submit')
->assertDispatched('success');
// Verify the database was updated
$githubApp->refresh();
expect($githubApp->app_id)->toBe(12345);
expect($githubApp->installation_id)->toBe(67890);
expect($githubApp->client_id)->toBe('new-client-id');
expect($githubApp->private_key_id)->toBe($privateKey->id);
});
test('validation allows nullable values for app configuration', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Test that validation passes with null values
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('submit')
->assertHasNoErrors();
});
test('createGithubAppManually redirects to avoid morphing issues', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Test that createGithubAppManually redirects instead of updating in place
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('createGithubAppManually')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid]));
// Verify the database was updated
$githubApp->refresh();
expect($githubApp->app_id)->toBe('1234567890');
expect($githubApp->installation_id)->toBe('1234567890');
});
test('checkPermissions validates required fields', function () {
// Create a GitHub app without app_id and private_key_id
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Test that checkPermissions fails with appropriate error
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('checkPermissions')
->assertDispatched('error', function ($event, $message) {
return str_contains($message, 'App ID') && str_contains($message, 'Private Key');
});
});
test('checkPermissions validates private key exists', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'private_key_id' => 99999, // Non-existent private key ID
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Test that checkPermissions fails when private key doesn't exist
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('checkPermissions')
->assertDispatched('error', function ($event, $message) {
return str_contains($message, 'Private Key not found');
});
});
});

View file

@ -0,0 +1,108 @@
<?php
use App\Livewire\Source\Github\Create;
use App\Models\GithubApp;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Set current team
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('GitHub Source Create Component', function () {
test('creates github app with default values', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'my-test-app')
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'my-test-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->name)->toBe('my-test-app');
expect($githubApp->api_url)->toBe('https://api.github.com');
expect($githubApp->html_url)->toBe('https://github.com');
expect($githubApp->custom_user)->toBe('git');
expect($githubApp->custom_port)->toBe(22);
expect($githubApp->is_system_wide)->toBeFalse();
expect($githubApp->team_id)->toBe($this->team->id);
});
test('creates github app with system wide enabled', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'system-wide-app')
->set('is_system_wide', true)
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'system-wide-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->is_system_wide)->toBeTrue();
});
test('creates github app with custom organization', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'org-app')
->set('organization', 'my-org')
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'org-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->organization)->toBe('my-org');
});
test('creates github app with custom git settings', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'enterprise-app')
->set('api_url', 'https://github.enterprise.com/api/v3')
->set('html_url', 'https://github.enterprise.com')
->set('custom_user', 'git-custom')
->set('custom_port', 2222)
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'enterprise-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.enterprise.com');
expect($githubApp->custom_user)->toBe('git-custom');
expect($githubApp->custom_port)->toBe(2222);
});
test('validates required fields', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', '')
->call('createGitHubApp')
->assertHasErrors(['name']);
});
test('redirects to github app show page after creation', function () {
$component = Livewire::test(Create::class)
->set('name', 'redirect-test')
->call('createGitHubApp');
$githubApp = GithubApp::where('name', 'redirect-test')->first();
$component->assertRedirect(route('source.github.show', ['github_app_uuid' => $githubApp->uuid]));
});
});

View file

@ -0,0 +1,63 @@
<?php
/**
* Test to verify that empty .env files are created for build packs that require them.
*
* This test verifies the fix for the issue where deploying a Docker image without
* environment variables would fail because Docker Compose expects a .env file
* when env_file: ['.env'] is specified in the compose file.
*
* The fix ensures that for 'dockerimage' and 'dockercompose' build packs,
* an empty .env file is created even when there are no environment variables defined.
*/
it('determines which build packs require empty .env file creation', function () {
// Build packs that set env_file: ['.env'] in the generated compose file
// and thus require an empty .env file even when no environment variables are defined
$buildPacksRequiringEnvFile = ['dockerimage', 'dockercompose'];
// Build packs that don't use env_file in the compose file
$buildPacksNotRequiringEnvFile = ['dockerfile', 'nixpacks', 'static'];
foreach ($buildPacksRequiringEnvFile as $buildPack) {
// Verify the condition matches our fix
$requiresEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
expect($requiresEnvFile)->toBeTrue("Build pack '{$buildPack}' should require empty .env file");
}
foreach ($buildPacksNotRequiringEnvFile as $buildPack) {
// These build packs also use env_file but call save_runtime_environment_variables()
// after generate_compose_file(), so they handle empty env files themselves
$requiresEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
expect($requiresEnvFile)->toBeFalse("Build pack '{$buildPack}' should not match the condition");
}
});
it('verifies dockerimage build pack is included in empty env file creation logic', function () {
$buildPack = 'dockerimage';
$shouldCreateEmptyEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
expect($shouldCreateEmptyEnvFile)->toBeTrue(
'dockerimage build pack should create empty .env file when no environment variables are defined'
);
});
it('verifies dockercompose build pack is included in empty env file creation logic', function () {
$buildPack = 'dockercompose';
$shouldCreateEmptyEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
expect($shouldCreateEmptyEnvFile)->toBeTrue(
'dockercompose build pack should create empty .env file when no environment variables are defined'
);
});
it('verifies other build packs are not included in empty env file creation logic', function () {
$otherBuildPacks = ['dockerfile', 'nixpacks', 'static', 'buildpack'];
foreach ($otherBuildPacks as $buildPack) {
$shouldCreateEmptyEnvFile = ($buildPack === 'dockercompose' || $buildPack === 'dockerimage');
expect($shouldCreateEmptyEnvFile)->toBeFalse(
"Build pack '{$buildPack}' should not create empty .env file in save_runtime_environment_variables()"
);
}
});

View file

@ -0,0 +1,57 @@
<?php
/**
* Tests for parseHealthcheckFromDockerfile method
*
* NOTE: These tests verify the logic for detecting when a HEALTHCHECK directive
* is removed from a Dockerfile. The fix ensures that healthcheck removal is detected
* regardless of the health_check_enabled setting.
*/
use App\Models\Application;
it('detects when HEALTHCHECK is removed from dockerfile', function () {
// This test verifies the fix for the bug where Coolify doesn't detect
// when a HEALTHCHECK is removed from a Dockerfile, causing deployments to fail.
$dockerfile = str("FROM nginx:latest\nCOPY . /app\nEXPOSE 80")->trim()->explode("\n");
// The key fix: hasHealthcheck check happens BEFORE the isHealthcheckDisabled check
$hasHealthcheck = str($dockerfile)->contains('HEALTHCHECK');
// Simulate an application with custom_healthcheck_found = true
$customHealthcheckFound = true;
// The fixed logic: This condition should be true when HEALTHCHECK is removed
$shouldReset = ! $hasHealthcheck && $customHealthcheckFound;
expect($shouldReset)->toBeTrue()
->and($hasHealthcheck)->toBeFalse()
->and($customHealthcheckFound)->toBeTrue();
});
it('does not reset when HEALTHCHECK exists in dockerfile', function () {
$dockerfile = str("FROM nginx:latest\nHEALTHCHECK --interval=30s CMD curl\nEXPOSE 80")->trim()->explode("\n");
$hasHealthcheck = str($dockerfile)->contains('HEALTHCHECK');
$customHealthcheckFound = true;
// When healthcheck exists, should not reset
$shouldReset = ! $hasHealthcheck && $customHealthcheckFound;
expect($shouldReset)->toBeFalse()
->and($hasHealthcheck)->toBeTrue();
});
it('does not reset when custom_healthcheck_found is false', function () {
$dockerfile = str("FROM nginx:latest\nCOPY . /app\nEXPOSE 80")->trim()->explode("\n");
$hasHealthcheck = str($dockerfile)->contains('HEALTHCHECK');
$customHealthcheckFound = false;
// When custom_healthcheck_found is false, no need to reset
$shouldReset = ! $hasHealthcheck && $customHealthcheckFound;
expect($shouldReset)->toBeFalse()
->and($customHealthcheckFound)->toBeFalse();
});

View file

@ -0,0 +1,100 @@
<?php
/**
* Unit tests to verify that docker_compose_raw only has content: removed from volumes,
* while docker_compose contains all Coolify additions (labels, environment variables, networks).
*
* These tests verify the fix for the issue where docker_compose_raw was being set to the
* fully processed compose (with Coolify labels, networks, etc.) instead of keeping it clean
* with only content: fields removed.
*/
it('ensures applicationParser stores original compose before processing', function () {
// Read the applicationParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that originalCompose is stored at the start of the function
expect($parsersFile)
->toContain('$compose = data_get($resource, \'docker_compose_raw\');')
->toContain('// Store original compose for later use to update docker_compose_raw with content removed')
->toContain('$originalCompose = $compose;');
});
it('ensures serviceParser stores original compose before processing', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that originalCompose is stored at the start of the function
expect($parsersFile)
->toContain('function serviceParser(Service $resource): Collection')
->toContain('$compose = data_get($resource, \'docker_compose_raw\');')
->toContain('// Store original compose for later use to update docker_compose_raw with content removed')
->toContain('$originalCompose = $compose;');
});
it('ensures applicationParser updates docker_compose_raw from original compose, not cleaned compose', function () {
// Read the applicationParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that docker_compose_raw is set from originalCompose, not cleanedCompose
expect($parsersFile)
->toContain('$originalYaml = Yaml::parse($originalCompose);')
->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);')
->not->toContain('$resource->docker_compose_raw = $cleanedCompose;');
});
it('ensures serviceParser updates docker_compose_raw from original compose, not cleaned compose', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Find the serviceParser function content
$serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection');
$serviceParserContent = substr($parsersFile, $serviceParserStart);
// Check that docker_compose_raw is set from originalCompose within serviceParser
expect($serviceParserContent)
->toContain('$originalYaml = Yaml::parse($originalCompose);')
->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);')
->not->toContain('$resource->docker_compose_raw = $cleanedCompose;');
});
it('ensures applicationParser removes content, isDirectory, and is_directory from volumes', function () {
// Read the applicationParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that content removal logic exists
expect($parsersFile)
->toContain('// Remove content, isDirectory, and is_directory from all volume definitions')
->toContain("unset(\$volume['content']);")
->toContain("unset(\$volume['isDirectory']);")
->toContain("unset(\$volume['is_directory']);");
});
it('ensures serviceParser removes content, isDirectory, and is_directory from volumes', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Find the serviceParser function content
$serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection');
$serviceParserContent = substr($parsersFile, $serviceParserStart);
// Check that content removal logic exists within serviceParser
expect($serviceParserContent)
->toContain('// Remove content, isDirectory, and is_directory from all volume definitions')
->toContain("unset(\$volume['content']);")
->toContain("unset(\$volume['isDirectory']);")
->toContain("unset(\$volume['is_directory']);");
});
it('ensures docker_compose_raw update is wrapped in try-catch for error handling', function () {
// Read the parsers file
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that docker_compose_raw update has error handling
expect($parsersFile)
->toContain('// Update docker_compose_raw to remove content: from volumes only')
->toContain('// This keeps the original user input clean while preventing content reapplication')
->toContain('try {')
->toContain('$originalYaml = Yaml::parse($originalCompose);')
->toContain('} catch (\Exception $e) {')
->toContain("ray('Failed to update docker_compose_raw");
});

View file

@ -0,0 +1,90 @@
<?php
use App\Models\Application;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Yaml\Yaml;
/**
* Integration test to verify docker_compose_raw remains clean after parsing
*/
it('verifies docker_compose_raw does not contain Coolify labels after parsing', function () {
// This test requires database, so skip if not available
if (! DB::connection()->getDatabaseName()) {
$this->markTestSkipped('Database not available');
}
// Create a simple compose file with volumes containing content
$originalCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- type: bind
source: ./config
target: /etc/nginx/conf.d
content: |
server {
listen 80;
}
labels:
- "my.custom.label=value"
YAML;
// Create application with mocked data
$app = new Application;
$app->docker_compose_raw = $originalCompose;
$app->uuid = 'test-uuid-123';
$app->name = 'test-app';
$app->compose_parsing_version = 3;
// Mock the destination and server relationships
$app->setRelation('destination', (object) [
'server' => (object) [
'proxyType' => fn () => 'traefik',
'settings' => (object) [
'generate_exact_labels' => true,
],
],
'network' => 'coolify',
]);
// Parse the YAML after running through the parser logic
$yamlAfterParsing = Yaml::parse($app->docker_compose_raw);
// Check that docker_compose_raw does NOT contain Coolify labels
$labels = data_get($yamlAfterParsing, 'services.web.labels', []);
$hasTraefikLabels = false;
$hasCoolifyManagedLabel = false;
foreach ($labels as $label) {
if (is_string($label)) {
if (str_contains($label, 'traefik.')) {
$hasTraefikLabels = true;
}
if (str_contains($label, 'coolify.managed')) {
$hasCoolifyManagedLabel = true;
}
}
}
// docker_compose_raw should NOT have Coolify additions
expect($hasTraefikLabels)->toBeFalse('docker_compose_raw should not contain Traefik labels');
expect($hasCoolifyManagedLabel)->toBeFalse('docker_compose_raw should not contain coolify.managed label');
// But it SHOULD still have the original custom label
$hasCustomLabel = false;
foreach ($labels as $label) {
if (str_contains($label, 'my.custom.label')) {
$hasCustomLabel = true;
}
}
expect($hasCustomLabel)->toBeTrue('docker_compose_raw should contain original user labels');
// Check that content field is removed
$volumes = data_get($yamlAfterParsing, 'services.web.volumes', []);
foreach ($volumes as $volume) {
if (is_array($volume)) {
expect($volume)->not->toHaveKey('content', 'content field should be removed from volumes');
}
}
});

View file

@ -0,0 +1,60 @@
<?php
use App\Jobs\ScheduledJobManager;
use Illuminate\Queue\Middleware\WithoutOverlapping;
it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks', function () {
$job = new ScheduledJobManager;
$middleware = $job->middleware();
// Assert middleware exists
expect($middleware)->toBeArray()
->and($middleware)->toHaveCount(1);
$overlappingMiddleware = $middleware[0];
// Assert it's a WithoutOverlapping instance
expect($overlappingMiddleware)->toBeInstanceOf(WithoutOverlapping::class);
// Use reflection to check private properties
$reflection = new ReflectionClass($overlappingMiddleware);
// Check expireAfter is set (should be 60 seconds - matches job frequency)
$expiresAfterProperty = $reflection->getProperty('expiresAfter');
$expiresAfterProperty->setAccessible(true);
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
expect($expiresAfter)->toBe(60)
->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks');
// Check releaseAfter is NOT set (we use dontRelease)
$releaseAfterProperty = $reflection->getProperty('releaseAfter');
$releaseAfterProperty->setAccessible(true);
$releaseAfter = $releaseAfterProperty->getValue($overlappingMiddleware);
expect($releaseAfter)->toBeNull('releaseAfter should be null when using dontRelease()');
// Check the lock key
$keyProperty = $reflection->getProperty('key');
$keyProperty->setAccessible(true);
$key = $keyProperty->getValue($overlappingMiddleware);
expect($key)->toBe('scheduled-job-manager');
});
it('prevents stale locks by ensuring expireAfter is always set', function () {
$job = new ScheduledJobManager;
$middleware = $job->middleware();
$overlappingMiddleware = $middleware[0];
$reflection = new ReflectionClass($overlappingMiddleware);
$expiresAfterProperty = $reflection->getProperty('expiresAfter');
$expiresAfterProperty->setAccessible(true);
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
// Critical check: expireAfter MUST be set to prevent GitHub issue #4539
expect($expiresAfter)->not->toBeNull(
'expireAfter() is required to prevent stale locks (see GitHub #4539)'
);
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.437"
"version": "4.0.0-beta.438"
},
"nightly": {
"version": "4.0.0-beta.438"
"version": "4.0.0-beta.439"
},
"helper": {
"version": "1.0.11"