v4.0.0-beta.452 (#7386)

This commit is contained in:
Andras Bacsai 2025-11-28 20:52:50 +01:00 committed by GitHub
commit a528f4c3d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 1296 additions and 304 deletions

View file

@ -39,7 +39,7 @@ public function handle(Application $application, bool $previewDeployments = fals
foreach ($containersToStop as $containerName) { foreach ($containersToStop as $containerName) {
instant_remote_process(command: [ instant_remote_process(command: [
"docker stop --time=30 $containerName", "docker stop -t 30 $containerName",
"docker rm -f $containerName", "docker rm -f $containerName",
], server: $server, throwError: false); ], server: $server, throwError: false);
} }

View file

@ -26,7 +26,7 @@ public function handle(Application $application, Server $server)
if ($containerName) { if ($containerName) {
instant_remote_process( instant_remote_process(
[ [
"docker stop --time=30 $containerName", "docker stop -t 30 $containerName",
"docker rm -f $containerName", "docker rm -f $containerName",
], ],
$server $server

View file

@ -105,7 +105,7 @@ public function handle(StandaloneClickhouse $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";

View file

@ -192,7 +192,7 @@ public function handle(StandaloneDragonfly $database)
if ($this->database->enable_ssl) { if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
} }
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";

View file

@ -208,7 +208,7 @@ public function handle(StandaloneKeydb $database)
if ($this->database->enable_ssl) { if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
} }
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";

View file

@ -209,7 +209,7 @@ public function handle(StandaloneMariadb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";

View file

@ -260,7 +260,7 @@ public function handle(StandaloneMongodb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) { if ($this->database->enable_ssl) {

View file

@ -210,7 +210,7 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";

View file

@ -223,7 +223,7 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) { if ($this->database->enable_ssl) {

View file

@ -205,7 +205,7 @@ public function handle(StandaloneRedis $database)
if ($this->database->enable_ssl) { if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
} }
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true"; $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";

View file

@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
{ {
$server = $database->destination->server; $server = $database->destination->server;
instant_remote_process(command: [ instant_remote_process(command: [
"docker stop --time=$timeout $containerName", "docker stop -t $timeout $containerName",
"docker rm -f $containerName", "docker rm -f $containerName",
], server: $server, throwError: false); ], server: $server, throwError: false);
} }

View file

@ -24,7 +24,7 @@ public function handle(Server $server, bool $forceStop = true, int $timeout = 30
} }
instant_remote_process(command: [ instant_remote_process(command: [
"docker stop --time=$timeout $containerName 2>/dev/null || true", "docker stop -t=$timeout $containerName 2>/dev/null || true",
"docker rm -f $containerName 2>/dev/null || true", "docker rm -f $containerName 2>/dev/null || true",
'# Wait for container to be fully removed', '# Wait for container to be fully removed',
'for i in {1..10}; do', 'for i in {1..10}; do',

View file

@ -3,6 +3,8 @@
namespace App\Actions\Server; namespace App\Actions\Server;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep; use Illuminate\Support\Sleep;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
@ -29,7 +31,59 @@ public function handle($manual_update = false)
return; return;
} }
CleanupDocker::dispatch($this->server, false, false); CleanupDocker::dispatch($this->server, false, false);
$this->latestVersion = get_latest_version_of_coolify();
// Fetch fresh version from CDN instead of using cache
try {
$response = Http::retry(3, 1000)->timeout(10)
->get(config('constants.coolify.versions_url'));
if ($response->successful()) {
$versions = $response->json();
$this->latestVersion = data_get($versions, 'coolify.v4.version');
} else {
// Fallback to cache if CDN unavailable
$cacheVersion = get_latest_version_of_coolify();
// Validate cache version against current running version
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
'cached_version' => $cacheVersion,
'current_version' => config('constants.coolify.version'),
]);
throw new \Exception(
'Cannot determine latest version: CDN unavailable and cache version '.
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
);
}
$this->latestVersion = $cacheVersion;
Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using validated cache', [
'version' => $cacheVersion,
]);
}
} catch (\Throwable $e) {
$cacheVersion = get_latest_version_of_coolify();
// Validate cache version against current running version
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
'error' => $e->getMessage(),
'cached_version' => $cacheVersion,
'current_version' => config('constants.coolify.version'),
]);
throw new \Exception(
'Cannot determine latest version: CDN unavailable and cache version '.
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
);
}
$this->latestVersion = $cacheVersion;
Log::warning('Failed to fetch fresh version from CDN, using validated cache', [
'error' => $e->getMessage(),
'version' => $cacheVersion,
]);
}
$this->currentVersion = config('constants.coolify.version'); $this->currentVersion = config('constants.coolify.version');
if (! $manual_update) { if (! $manual_update) {
if (! $settings->is_auto_update_enabled) { if (! $settings->is_auto_update_enabled) {
@ -42,6 +96,20 @@ public function handle($manual_update = false)
return; return;
} }
} }
// ALWAYS check for downgrades (even for manual updates)
if (version_compare($this->latestVersion, $this->currentVersion, '<')) {
Log::error('Downgrade prevented', [
'target_version' => $this->latestVersion,
'current_version' => $this->currentVersion,
'manual_update' => $manual_update,
]);
throw new \Exception(
"Cannot downgrade from {$this->currentVersion} to {$this->latestVersion}. ".
'If you need to downgrade, please do so manually via Docker commands.'
);
}
$this->update(); $this->update();
$settings->new_version_available = false; $settings->new_version_available = false;
$settings->save(); $settings->save();
@ -56,8 +124,9 @@ private function update()
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion; $image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false); instant_remote_process(["docker pull -q $image"], $this->server, false);
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
remote_process([ remote_process([
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh', "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion", "bash /data/coolify/source/upgrade.sh $this->latestVersion",
], $this->server); ], $this->server);
} }

View file

@ -54,7 +54,7 @@ private function stopContainersInParallel(array $containersToStop, Server $serve
$timeout = count($containersToStop) > 5 ? 10 : 30; $timeout = count($containersToStop) > 5 ? 10 : 30;
$commands = []; $commands = [];
$containerList = implode(' ', $containersToStop); $containerList = implode(' ', $containersToStop);
$commands[] = "docker stop --time=$timeout $containerList"; $commands[] = "docker stop -t $timeout $containerList";
$commands[] = "docker rm -f $containerList"; $commands[] = "docker rm -f $containerList";
instant_remote_process( instant_remote_process(
command: $commands, command: $commands,

View file

@ -56,7 +56,7 @@ private function showHelp()
php artisan app:demo-notify {channel} php artisan app:demo-notify {channel}
</p> </p>
<div class="my-1"> <div class="my-1">
<div class="text-yellow-500"> Channels: </div> <div class="text-warning-500"> Channels: </div>
<ul class="text-coolify"> <ul class="text-coolify">
<li>email</li> <li>email</li>
<li>discord</li> <li>discord</li>

View file

@ -1652,6 +1652,10 @@ private function create_application(Request $request, $type)
$service->save(); $service->save();
$service->parse(isNew: true); $service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
if ($instantDeploy) { if ($instantDeploy) {
StartService::dispatch($service); StartService::dispatch($service);
} }

View file

@ -351,7 +351,7 @@ public function create_service(Request $request)
'destination_id' => $destination->id, 'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(), 'destination_type' => $destination->getMorphClass(),
]; ];
if ($oneClickServiceName === 'cloudflared') { if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($servicePayload, 'connect_to_docker_network', true); data_set($servicePayload, 'connect_to_docker_network', true);
} }
$service = Service::create($servicePayload); $service = Service::create($servicePayload);
@ -376,6 +376,10 @@ public function create_service(Request $request)
}); });
} }
$service->parse(isNew: true); $service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
if ($instantDeploy) { if ($instantDeploy) {
StartService::dispatch($service); StartService::dispatch($service);
} }

View file

@ -1401,15 +1401,44 @@ private function generate_buildtime_environment_variables()
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
} }
$envs = collect([]); // Use associative array for automatic deduplication
$envs_dict = [];
// 1. Add nixpacks plan variables FIRST (lowest priority - can be overridden)
if ($this->build_pack === 'nixpacks' &&
isset($this->nixpacks_plan_json) &&
$this->nixpacks_plan_json->isNotEmpty()) {
$planVariables = data_get($this->nixpacks_plan_json, 'variables', []);
if (! empty($planVariables)) {
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] Adding '.count($planVariables).' nixpacks plan variables to buildtime.env');
}
foreach ($planVariables as $key => $value) {
// Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority
if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) {
continue;
}
$escapedValue = escapeBashEnvValue($value);
$envs_dict[$key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Nixpacks var: {$key}={$escapedValue}");
}
}
}
}
// 2. Add COOLIFY variables (can override nixpacks, but shouldn't happen in practice)
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
foreach ($coolify_envs as $key => $item) {
$envs_dict[$key] = escapeBashEnvValue($item);
}
// Add COOLIFY variables // 3. Add SERVICE_NAME, SERVICE_FQDN, SERVICE_URL variables for Docker Compose builds
$coolify_envs->each(function ($item, $key) use ($envs) {
$envs->push($key.'='.escapeBashEnvValue($item));
});
// Add SERVICE_NAME variables for Docker Compose builds
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
// Generate SERVICE_NAME for dockercompose services from processed compose // Generate SERVICE_NAME for dockercompose services from processed compose
@ -1420,7 +1449,7 @@ private function generate_buildtime_environment_variables()
} }
$services = data_get($dockerCompose, 'services', []); $services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) { foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName)); $envs_dict['SERVICE_NAME_'.str($serviceName)->upper()] = escapeBashEnvValue($serviceName);
} }
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
@ -1433,8 +1462,8 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme(); $coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost(); $coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString())); $envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn)); $envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
} }
} }
} else { } else {
@ -1442,7 +1471,7 @@ private function generate_buildtime_environment_variables()
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []); $rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) { foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id))); $envs_dict['SERVICE_NAME_'.str($rawServiceName)->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
} }
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
@ -1455,17 +1484,16 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme(); $coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost(); $coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString())); $envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn)); $envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
} }
} }
} }
} }
// Add build-time user variables only // 4. Add user-defined build-time variables LAST (highest priority - can override everything)
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables() $sorted_environment_variables = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables ->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get(); ->get();
@ -1483,7 +1511,12 @@ private function generate_buildtime_environment_variables()
// Strip outer quotes from real_value and apply proper bash escaping // Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'"); $value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value); $escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
}
$envs_dict[$env->key] = $escapedValue;
if (isDev()) { if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@ -1495,7 +1528,12 @@ private function generate_buildtime_environment_variables()
} else { } else {
// For normal vars, use double quotes to allow $VAR expansion // For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value); $escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
}
$envs_dict[$env->key] = $escapedValue;
if (isDev()) { if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@ -1507,7 +1545,6 @@ private function generate_buildtime_environment_variables()
} }
} else { } else {
$sorted_environment_variables = $this->application->environment_variables_preview() $sorted_environment_variables = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables ->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get(); ->get();
@ -1525,7 +1562,12 @@ private function generate_buildtime_environment_variables()
// Strip outer quotes from real_value and apply proper bash escaping // Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'"); $value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value); $escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
}
$envs_dict[$env->key] = $escapedValue;
if (isDev()) { if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@ -1537,7 +1579,12 @@ private function generate_buildtime_environment_variables()
} else { } else {
// For normal vars, use double quotes to allow $VAR expansion // For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value); $escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
}
$envs_dict[$env->key] = $escapedValue;
if (isDev()) { if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@ -1549,6 +1596,12 @@ private function generate_buildtime_environment_variables()
} }
} }
// Convert dictionary back to collection in KEY=VALUE format
$envs = collect([]);
foreach ($envs_dict as $key => $value) {
$envs->push($key.'='.$value);
}
// Return the generated environment variables // Return the generated environment variables
if (isDev()) { if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
@ -3090,7 +3143,7 @@ private function graceful_shutdown_container(string $containerName)
try { try {
$timeout = isDev() ? 1 : 30; $timeout = isDev() ? 1 : 30;
$this->execute_remote_command( $this->execute_remote_command(
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
); );
} catch (Exception $error) { } catch (Exception $error) {

View file

@ -10,6 +10,7 @@
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
{ {
@ -22,20 +23,60 @@ public function handle(): void
return; return;
} }
$settings = instanceSettings(); $settings = instanceSettings();
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json'); $response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
if ($response->successful()) { if ($response->successful()) {
$versions = $response->json(); $versions = $response->json();
$latest_version = data_get($versions, 'coolify.v4.version'); $latest_version = data_get($versions, 'coolify.v4.version');
$current_version = config('constants.coolify.version'); $current_version = config('constants.coolify.version');
// Read existing cached version
$existingVersions = null;
$existingCoolifyVersion = null;
if (File::exists(base_path('versions.json'))) {
$existingVersions = json_decode(File::get(base_path('versions.json')), true);
$existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version');
}
// Determine the BEST version to use (CDN, cache, or current)
$bestVersion = $latest_version;
// Check if cache has newer version than CDN
if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) {
Log::warning('CDN served older Coolify version than cache', [
'cdn_version' => $latest_version,
'cached_version' => $existingCoolifyVersion,
'current_version' => $current_version,
]);
$bestVersion = $existingCoolifyVersion;
}
// CRITICAL: Never allow bestVersion to be older than currently running version
if (version_compare($bestVersion, $current_version, '<')) {
Log::warning('Version downgrade prevented in CheckForUpdatesJob', [
'cdn_version' => $latest_version,
'cached_version' => $existingCoolifyVersion,
'current_version' => $current_version,
'attempted_best' => $bestVersion,
'using' => $current_version,
]);
$bestVersion = $current_version;
}
// Use data_set() for safe mutation (fixes #3)
data_set($versions, 'coolify.v4.version', $bestVersion);
$latest_version = $bestVersion;
// ALWAYS write versions.json (for Sentinel, Helper, Traefik updates)
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
// Invalidate cache to ensure fresh data is loaded
invalidate_versions_cache();
// Only mark new version available if Coolify version actually increased
if (version_compare($latest_version, $current_version, '>')) { if (version_compare($latest_version, $current_version, '>')) {
// New version available // New version available
$settings->update(['new_version_available' => true]); $settings->update(['new_version_available' => true]);
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
// Invalidate cache to ensure fresh data is loaded
invalidate_versions_cache();
} else { } else {
$settings->update(['new_version_available' => false]); $settings->update(['new_version_available' => false]);
} }

View file

@ -21,7 +21,7 @@ public function __construct() {}
public function handle(): void public function handle(): void
{ {
try { try {
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json'); $response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
if ($response->successful()) { if ($response->successful()) {
$versions = $response->json(); $versions = $response->json();
$settings = instanceSettings(); $settings = instanceSettings();

View file

@ -191,7 +191,7 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
$containerList = implode(' ', array_map('escapeshellarg', $containerNames)); $containerList = implode(' ', array_map('escapeshellarg', $containerNames));
$commands = [ $commands = [
"docker stop --time=$timeout $containerList", "docker stop -t $timeout $containerList",
"docker rm -f $containerList", "docker rm -f $containerList",
]; ];
instant_remote_process( instant_remote_process(

View file

@ -278,7 +278,7 @@ private function stopContainers(array $containers, $server)
foreach ($containersToStop as $containerName) { foreach ($containersToStop as $containerName) {
instant_remote_process(command: [ instant_remote_process(command: [
"docker stop --time=30 $containerName", "docker stop -t 30 $containerName",
"docker rm -f $containerName", "docker rm -f $containerName",
], server: $server, throwError: false); ], server: $server, throwError: false);
} }

View file

@ -74,6 +74,9 @@ public function submit()
} }
$service->parse(isNew: true); $service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
return redirect()->route('project.service.configuration', [ return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid, 'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid, 'environment_uuid' => $environment->uuid,

View file

@ -81,7 +81,7 @@ public function mount()
'destination_id' => $destination->id, 'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(), 'destination_type' => $destination->getMorphClass(),
]; ];
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') { if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($service_payload, 'connect_to_docker_network', true); data_set($service_payload, 'connect_to_docker_network', true);
} }
$service = Service::create($service_payload); $service = Service::create($service_payload);
@ -104,6 +104,9 @@ public function mount()
} }
$service->parse(isNew: true); $service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
return redirect()->route('project.service.configuration', [ return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid, 'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid, 'environment_uuid' => $environment->uuid,

View file

@ -39,7 +39,7 @@ class GetLogs extends Component
public ?bool $streamLogs = false; public ?bool $streamLogs = false;
public ?bool $showTimeStamps = true; public ?bool $showTimeStamps = false;
public ?int $numberOfLines = 100; public ?int $numberOfLines = 100;

View file

@ -10,6 +10,7 @@
use App\Models\Server; use App\Models\Server;
use App\Services\ProxyDashboardCacheService; use App\Services\ProxyDashboardCacheService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Log;
use Livewire\Component; use Livewire\Component;
class Navbar extends Component class Navbar extends Component
@ -72,7 +73,15 @@ public function restart()
// Check Traefik version after restart to provide immediate feedback // Check Traefik version after restart to provide immediate feedback
if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) { if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) {
CheckTraefikVersionForServerJob::dispatch($this->server, get_traefik_versions()); $traefikVersions = get_traefik_versions();
if ($traefikVersions !== null) {
CheckTraefikVersionForServerJob::dispatch($this->server, $traefikVersions);
} else {
Log::warning('Traefik version check skipped: versions.json data unavailable', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
}
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);

View file

@ -79,20 +79,19 @@ protected function getTraefikVersions(): ?array
// Load from global cached helper (Redis + filesystem) // Load from global cached helper (Redis + filesystem)
$versionsData = get_versions_data(); $versionsData = get_versions_data();
$this->cachedVersionsFile = $versionsData;
if (! $versionsData) { if (! $versionsData) {
return null; return null;
} }
$this->cachedVersionsFile = $versionsData;
$traefikVersions = data_get($versionsData, 'traefik'); $traefikVersions = data_get($versionsData, 'traefik');
return is_array($traefikVersions) ? $traefikVersions : null; return is_array($traefikVersions) ? $traefikVersions : null;
} }
public function getConfigurationFilePathProperty() public function getConfigurationFilePathProperty(): string
{ {
return $this->server->proxyPath().'docker-compose.yml'; return rtrim($this->server->proxyPath(), '/').'/docker-compose.yml';
} }
public function changeProxy() public function changeProxy()

View file

@ -8,8 +8,6 @@
class Upgrade extends Component class Upgrade extends Component
{ {
public bool $showProgress = false;
public bool $updateInProgress = false; public bool $updateInProgress = false;
public bool $isUpgradeAvailable = false; public bool $isUpgradeAvailable = false;

View file

@ -67,4 +67,14 @@
'alpine', 'alpine',
]; ];
const NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK = [
'pgadmin',
'postgresus',
];
const NEEDS_TO_DISABLE_GZIP = [
'beszel' => ['beszel'],
];
const NEEDS_TO_DISABLE_STRIPPREFIX = [
'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'],
];
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment']; const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment'];

View file

@ -6,6 +6,20 @@
use App\Models\Server; use App\Models\Server;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
/**
* Check if a network name is a Docker predefined system network.
* These networks cannot be created, modified, or managed by docker network commands.
*
* @param string $network Network name to check
* @return bool True if it's a predefined network that should be skipped
*/
function isDockerPredefinedNetwork(string $network): bool
{
// Only filter 'default' and 'host' to match existing codebase patterns
// See: bootstrap/helpers/parsers.php:891, bootstrap/helpers/shared.php:689,748
return in_array($network, ['default', 'host'], true);
}
function collectProxyDockerNetworksByServer(Server $server) function collectProxyDockerNetworksByServer(Server $server)
{ {
if (! $server->isFunctional()) { if (! $server->isFunctional()) {
@ -66,8 +80,12 @@ function collectDockerNetworksByServer(Server $server)
$networks->push($network); $networks->push($network);
$allNetworks->push($network); $allNetworks->push($network);
} }
$networks = collect($networks)->flatten()->unique(); $networks = collect($networks)->flatten()->unique()->filter(function ($network) {
$allNetworks = $allNetworks->flatten()->unique(); return ! isDockerPredefinedNetwork($network);
});
$allNetworks = $allNetworks->flatten()->unique()->filter(function ($network) {
return ! isDockerPredefinedNetwork($network);
});
if ($server->isSwarm()) { if ($server->isSwarm()) {
if ($networks->count() === 0) { if ($networks->count() === 0) {
$networks = collect(['coolify-overlay']); $networks = collect(['coolify-overlay']);
@ -219,8 +237,8 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
$array_of_networks = collect([]); $array_of_networks = collect([]);
$filtered_networks = collect([]); $filtered_networks = collect([]);
$networks->map(function ($network) use ($array_of_networks, $filtered_networks) { $networks->map(function ($network) use ($array_of_networks, $filtered_networks) {
if ($network === 'host') { if (isDockerPredefinedNetwork($network)) {
return; // network-scoped alias is supported only for containers in user defined networks return; // Predefined networks cannot be used in network configuration
} }
$array_of_networks[$network] = [ $array_of_networks[$network] = [

View file

@ -4,6 +4,7 @@
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Stringable; use Illuminate\Support\Stringable;
use Spatie\Url\Url; use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@ -339,3 +340,54 @@ function parseServiceEnvironmentVariable(string $key): array
'has_port' => $hasPort, 'has_port' => $hasPort,
]; ];
} }
/**
* Apply service-specific application prerequisites after service parse.
*
* This function configures application-level settings that are required for
* specific one-click services to work correctly (e.g., disabling gzip for Beszel,
* disabling strip prefix for Appwrite services).
*
* Must be called AFTER $service->parse() since it requires applications to exist.
*
* @param Service $service The service to apply prerequisites to
*/
function applyServiceApplicationPrerequisites(Service $service): void
{
try {
// Extract service name from service name (format: "servicename-uuid")
$serviceName = str($service->name)->beforeLast('-')->value();
// Apply gzip disabling if needed
if (array_key_exists($serviceName, NEEDS_TO_DISABLE_GZIP)) {
$applicationNames = NEEDS_TO_DISABLE_GZIP[$serviceName];
foreach ($applicationNames as $applicationName) {
$application = $service->applications()->whereName($applicationName)->first();
if ($application) {
$application->is_gzip_enabled = false;
$application->save();
}
}
}
// Apply stripprefix disabling if needed
if (array_key_exists($serviceName, NEEDS_TO_DISABLE_STRIPPREFIX)) {
$applicationNames = NEEDS_TO_DISABLE_STRIPPREFIX[$serviceName];
foreach ($applicationNames as $applicationName) {
$application = $service->applications()->whereName($applicationName)->first();
if ($application) {
$application->is_stripprefix_enabled = false;
$application->save();
}
}
}
} catch (\Throwable $e) {
// Log error but don't throw - prerequisites are nice-to-have, not critical
Log::error('Failed to apply service application prerequisites', [
'service_id' => $service->id,
'service_name' => $service->name,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}

View file

@ -230,7 +230,7 @@ function get_route_parameters(): array
function get_latest_sentinel_version(): string function get_latest_sentinel_version(): string
{ {
try { try {
$response = Http::get('https://cdn.coollabs.io/coolify/versions.json'); $response = Http::get(config('constants.coolify.versions_url'));
$versions = $response->json(); $versions = $response->json();
return data_get($versions, 'coolify.sentinel.version'); return data_get($versions, 'coolify.sentinel.version');

View file

@ -2,7 +2,7 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => '4.0.0-beta.451', 'version' => '4.0.0-beta.452',
'helper_version' => '1.0.12', 'helper_version' => '1.0.12',
'realtime_version' => '1.0.10', 'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),
@ -12,6 +12,9 @@
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'), 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json', 'releases_url' => 'https://cdn.coolify.io/releases.json',
], ],

View file

@ -35,6 +35,7 @@ public function up(): void
$table->boolean('server_reachable_webhook_notifications')->default(false); $table->boolean('server_reachable_webhook_notifications')->default(false);
$table->boolean('server_unreachable_webhook_notifications')->default(true); $table->boolean('server_unreachable_webhook_notifications')->default(true);
$table->boolean('server_patch_webhook_notifications')->default(false); $table->boolean('server_patch_webhook_notifications')->default(false);
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
$table->unique(['team_id']); $table->unique(['team_id']);
}); });

View file

@ -19,9 +19,13 @@ public function up(): void
$table->boolean('traefik_outdated_slack_notifications')->default(true); $table->boolean('traefik_outdated_slack_notifications')->default(true);
}); });
Schema::table('webhook_notification_settings', function (Blueprint $table) { // Only add if table exists and column doesn't exist
$table->boolean('traefik_outdated_webhook_notifications')->default(true); if (Schema::hasTable('webhook_notification_settings') &&
}); ! Schema::hasColumn('webhook_notification_settings', 'traefik_outdated_webhook_notifications')) {
Schema::table('webhook_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
});
}
Schema::table('telegram_notification_settings', function (Blueprint $table) { Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_telegram_notifications')->default(true); $table->boolean('traefik_outdated_telegram_notifications')->default(true);
@ -45,9 +49,13 @@ public function down(): void
$table->dropColumn('traefik_outdated_slack_notifications'); $table->dropColumn('traefik_outdated_slack_notifications');
}); });
Schema::table('webhook_notification_settings', function (Blueprint $table) { // Only drop if table and column exist
$table->dropColumn('traefik_outdated_webhook_notifications'); if (Schema::hasTable('webhook_notification_settings') &&
}); Schema::hasColumn('webhook_notification_settings', 'traefik_outdated_webhook_notifications')) {
Schema::table('webhook_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_webhook_notifications');
});
}
Schema::table('telegram_notification_settings', function (Blueprint $table) { Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_telegram_notifications'); $table->dropColumn('traefik_outdated_telegram_notifications');

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.451" "version": "4.0.0-beta.452"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.452" "version": "4.0.0-beta.453"
}, },
"helper": { "helper": {
"version": "1.0.12" "version": "1.0.12"

View file

@ -18,6 +18,16 @@ @theme {
--color-base: #101010; --color-base: #101010;
--color-warning: #fcd452; --color-warning: #fcd452;
--color-warning-50: #fefce8;
--color-warning-100: #fef9c3;
--color-warning-200: #fef08a;
--color-warning-300: #fde047;
--color-warning-400: #fcd452;
--color-warning-500: #facc15;
--color-warning-600: #ca8a04;
--color-warning-700: #a16207;
--color-warning-800: #854d0e;
--color-warning-900: #713f12;
--color-success: #22C55E; --color-success: #22C55E;
--color-error: #dc2626; --color-error: #dc2626;
--color-coollabs-50: #f5f0ff; --color-coollabs-50: #f5f0ff;

View file

@ -49,7 +49,7 @@ @utility input-sticky {
} }
@utility input-sticky-active { @utility input-sticky-active {
@apply text-black border-2 border-coollabs dark:text-white focus:bg-neutral-200 dark:focus:bg-coolgray-400 focus:border-coollabs; @apply text-black border-2 border-coollabs dark:border-warning dark:text-white focus:bg-neutral-200 dark:focus:bg-coolgray-400 focus:border-coollabs dark:focus:border-warning;
} }
/* Focus */ /* Focus */
@ -154,7 +154,7 @@ @utility badge {
} }
@utility badge-dashboard { @utility badge-dashboard {
@apply absolute top-0 right-0 w-2.5 h-2.5 rounded-bl-full text-xs font-bold leading-none border border-neutral-200 dark:border-black; @apply absolute top-1 right-1 w-2.5 h-2.5 rounded-full text-xs font-bold leading-none border border-neutral-200 dark:border-black;
} }
@utility badge-success { @utility badge-success {
@ -229,6 +229,10 @@ @utility box-without-bg-without-border {
@apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem]; @apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem];
} }
@utility coolbox {
@apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded-lg border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem];
}
@utility on-box { @utility on-box {
@apply rounded-sm hover:bg-neutral-300 dark:hover:bg-coolgray-500/20; @apply rounded-sm hover:bg-neutral-300 dark:hover:bg-coolgray-500/20;
} }

View file

@ -2,7 +2,7 @@
@php @php
$icons = [ $icons = [
'warning' => '<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>', 'warning' => '<svg class="w-5 h-5 text-warning-600 dark:text-warning-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>',
'danger' => '<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg>', 'danger' => '<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg>',
@ -13,10 +13,10 @@
$colors = [ $colors = [
'warning' => [ 'warning' => [
'bg' => 'bg-yellow-50 dark:bg-yellow-900/30', 'bg' => 'bg-warning-50 dark:bg-warning-900/30',
'border' => 'border-yellow-300 dark:border-yellow-800', 'border' => 'border-warning-300 dark:border-warning-800',
'title' => 'text-yellow-800 dark:text-yellow-300', 'title' => 'text-warning-800 dark:text-warning-300',
'text' => 'text-yellow-700 dark:text-yellow-200' 'text' => 'text-warning-700 dark:text-warning-200'
], ],
'danger' => [ 'danger' => [
'bg' => 'bg-red-50 dark:bg-red-900/30', 'bg' => 'bg-red-50 dark:bg-red-900/30',

View file

@ -1,27 +1,22 @@
<div @class([ <div @class([
'transition-all duration-150 box-without-bg dark:bg-coolgray-100 bg-white group', 'coolbox group',
'hover:border-l-coollabs cursor-pointer' => !$upgrade, '!cursor-not-allowed hover:border-l-red-500' => $upgrade,
'hover:border-l-red-500 cursor-not-allowed' => $upgrade, ])>
])>
<div class="flex items-center"> <div class="flex items-center">
<div class="w-[4.5rem] h-[4.5rem] flex items-center justify-center text-black dark:text-white shrink-0"> <div class="w-[4.5rem] h-[4.5rem] flex items-center justify-center text-black dark:text-white shrink-0 rounded-lg overflow-hidden">
{{ $logo }} {{ $logo }}
</div> </div>
<div class="flex flex-col pl-2 "> <div class="flex flex-col pl-3 space-y-1">
<div class="dark:text-white text-md"> <div class="dark:text-white text-md font-medium">
{{ $title }} {{ $title }}
</div> </div>
@if ($upgrade) @if ($upgrade)
<div>{{ $upgrade }}</div> <div>{{ $upgrade }}</div>
@else @else
<div class="text-xs font-bold dark:text-neutral-500 dark:group-hover:text-neutral-300"> <div class="text-xs dark:text-neutral-400 dark:group-hover:text-neutral-200 line-clamp-2">
{{ $description }} {{ $description }}
</div> </div>
@endif @endif
</div> </div>
</div> </div>
@isset($documentation)
<div class="flex-1"></div>
{{ $documentation }}
@endisset
</div> </div>

View file

@ -17,7 +17,7 @@
@if ($foundUsers->count() > 0) @if ($foundUsers->count() > 0)
<div class="flex flex-wrap gap-2 pt-4"> <div class="flex flex-wrap gap-2 pt-4">
@foreach ($foundUsers as $user) @foreach ($foundUsers as $user)
<div class="box w-64 group" wire:click="switchUser({{ $user->id }})"> <div class="coolbox w-64 group" wire:click="switchUser({{ $user->id }})">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="box-title">{{ $user->name }}</div> <div class="box-title">{{ $user->name }}</div>
<div class="box-description">{{ $user->email }}</div> <div class="box-description">{{ $user->email }}</div>

View file

@ -35,7 +35,7 @@ class="flex items-center justify-center size-4 text-white rounded hover:bg-coolg
@if ($projects->count() > 0) @if ($projects->count() > 0)
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2"> <div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
@foreach ($projects as $project) @foreach ($projects as $project)
<div class="relative gap-2 cursor-pointer box group"> <div class="relative gap-2 cursor-pointer coolbox group">
<a href="{{ $project->navigateTo() }}" class="absolute inset-0"></a> <a href="{{ $project->navigateTo() }}" class="absolute inset-0"></a>
<div class="flex flex-1 mx-6"> <div class="flex flex-1 mx-6">
<div class="flex flex-col justify-center flex-1"> <div class="flex flex-col justify-center flex-1">
@ -103,7 +103,7 @@ class="flex items-center justify-center size-4 text-white rounded hover:bg-coolg
@foreach ($servers as $server) @foreach ($servers as $server)
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}" <a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}"
@class([ @class([
'gap-2 border cursor-pointer box group', 'gap-2 border cursor-pointer coolbox group',
'border-red-500' => 'border-red-500' =>
!$server->settings->is_reachable || $server->settings->force_disabled, !$server->settings->is_reachable || $server->settings->force_disabled,
])> ])>

View file

@ -17,7 +17,7 @@
@forelse ($servers as $server) @forelse ($servers as $server)
@forelse ($server->destinations() as $destination) @forelse ($server->destinations() as $destination)
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker') @if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
<a class="box group" <a class="coolbox group"
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}"> href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col justify-center mx-6"> <div class="flex flex-col justify-center mx-6">
<div class="box-title">{{ $destination->name }}</div> <div class="box-title">{{ $destination->name }}</div>
@ -26,7 +26,7 @@
</a> </a>
@endif @endif
@if ($destination->getMorphClass() === 'App\Models\SwarmDocker') @if ($destination->getMorphClass() === 'App\Models\SwarmDocker')
<a class="box group" <a class="coolbox group"
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}"> href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
<div class="box-title">{{ $destination->name }}</div> <div class="box-title">{{ $destination->name }}</div>

View file

@ -328,7 +328,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div> </div>
@if ($loadingServers) @if ($loadingServers)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg" <svg class="animate-spin h-5 w-5 text-warning-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24"> fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle> stroke-width="4"></circle>
@ -342,7 +342,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@elseif (count($availableServers) > 0) @elseif (count($availableServers) > 0)
@foreach ($availableServers as $index => $server) @foreach ($availableServers as $index => $server)
<button type="button" wire:click="selectServer({{ $server['id'] }}, true)" <button type="button" wire:click="selectServer({{ $server['id'] }}, true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0"> class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]"> <div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white"> <div class="font-medium text-neutral-900 dark:text-white">
@ -359,7 +359,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
@endif @endif
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none" class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" /> d="M9 5l7 7-7 7" />
@ -403,7 +403,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div> </div>
@if ($loadingDestinations) @if ($loadingDestinations)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg" <svg class="animate-spin h-5 w-5 text-warning-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24"> fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle> stroke-width="4"></circle>
@ -417,7 +417,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@elseif (count($availableDestinations) > 0) @elseif (count($availableDestinations) > 0)
@foreach ($availableDestinations as $index => $destination) @foreach ($availableDestinations as $index => $destination)
<button type="button" wire:click="selectDestination('{{ $destination['uuid'] }}', true)" <button type="button" wire:click="selectDestination('{{ $destination['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0"> class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]"> <div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white"> <div class="font-medium text-neutral-900 dark:text-white">
@ -428,7 +428,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
</div> </div>
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none" class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" /> d="M9 5l7 7-7 7" />
@ -472,7 +472,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div> </div>
@if ($loadingProjects) @if ($loadingProjects)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg" <svg class="animate-spin h-5 w-5 text-warning-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24"> fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle> stroke-width="4"></circle>
@ -486,7 +486,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@elseif (count($availableProjects) > 0) @elseif (count($availableProjects) > 0)
@foreach ($availableProjects as $index => $project) @foreach ($availableProjects as $index => $project)
<button type="button" wire:click="selectProject('{{ $project['uuid'] }}', true)" <button type="button" wire:click="selectProject('{{ $project['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0"> class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]"> <div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white"> <div class="font-medium text-neutral-900 dark:text-white">
@ -503,7 +503,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
@endif @endif
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none" class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" /> d="M9 5l7 7-7 7" />
@ -547,7 +547,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div> </div>
@if ($loadingEnvironments) @if ($loadingEnvironments)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg" <svg class="animate-spin h-5 w-5 text-warning-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24"> fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle> stroke-width="4"></circle>
@ -561,7 +561,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@elseif (count($availableEnvironments) > 0) @elseif (count($availableEnvironments) > 0)
@foreach ($availableEnvironments as $index => $environment) @foreach ($availableEnvironments as $index => $environment)
<button type="button" wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)" <button type="button" wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0"> class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]"> <div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white"> <div class="font-medium text-neutral-900 dark:text-white">
@ -578,7 +578,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
@endif @endif
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none" class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" /> d="M9 5l7 7-7 7" />
@ -616,7 +616,7 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr
@foreach ($searchResults as $result) @foreach ($searchResults as $result)
@if (!isset($result['is_creatable_suggestion'])) @if (!isset($result['is_creatable_suggestion']))
<a href="{{ $result['link'] ?? '#' }}" <a href="{{ $result['link'] ?? '#' }}"
class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-yellow-50 dark:focus:bg-yellow-900/20 border-transparent hover:border-coollabs focus:border-yellow-500 dark:focus:border-yellow-400"> class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-warning-50 dark:focus:bg-warning-900/20 border-transparent hover:border-coollabs focus:border-warning-500 dark:focus:border-warning-400">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
@ -680,13 +680,13 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr
<!-- Category Items --> <!-- Category Items -->
@foreach ($items as $item) @foreach ($items as $item)
<button type="button" wire:click="navigateToResource('{{ $item['type'] }}')" <button type="button" wire:click="navigateToResource('{{ $item['type'] }}')"
class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-transparent hover:border-yellow-500 focus:border-yellow-500"> class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 flex-1 min-w-0"> <div class="flex items-center gap-3 flex-1 min-w-0">
<div <div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center"> class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="none" class="h-5 w-5 text-warning-600 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" /> d="M12 4v16m8-8H4" />
@ -708,7 +708,7 @@ class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickc
</div> </div>
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center" class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400 self-center"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" /> d="M9 5l7 7-7 7" />
@ -733,7 +733,7 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr
</template> </template>
<template x-for="(result, index) in searchResults" :key="index"> <template x-for="(result, index) in searchResults" :key="index">
<a :href="result.link || '#'" <a :href="result.link || '#'"
class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-yellow-50 dark:focus:bg-yellow-900/20 border-transparent hover:border-coollabs focus:border-yellow-500 dark:focus:border-yellow-400"> class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-warning-50 dark:focus:bg-warning-900/20 border-transparent hover:border-coollabs focus:border-warning-500 dark:focus:border-warning-400">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
@ -789,13 +789,13 @@ class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
<template x-for="item in items" :key="item.type"> <template x-for="item in items" :key="item.type">
<button type="button" @click="$wire.navigateToResource(item.type)" <button type="button" @click="$wire.navigateToResource(item.type)"
class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-transparent hover:border-yellow-500 focus:border-yellow-500"> class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 flex-1 min-w-0"> <div class="flex items-center gap-3 flex-1 min-w-0">
<div <div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center"> class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400" class="h-5 w-5 text-warning-600 dark:text-warning-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4v16m8-8H4" /> stroke-width="2" d="M12 4v16m8-8H4" />
@ -818,7 +818,7 @@ class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0"
</div> </div>
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center" class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400 self-center"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 5l7 7-7 7" /> stroke-width="2" d="M9 5l7 7-7 7" />

View file

@ -386,7 +386,7 @@
@if ($this->detectedPortInfo) @if ($this->detectedPortInfo)
@if ($this->detectedPortInfo['isEmpty']) @if ($this->detectedPortInfo['isEmpty'])
<div <div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800"> class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-warning-50 dark:bg-warning-900/20 text-warning-800 dark:text-warning-300 border border-warning-200 dark:border-warning-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
@ -402,7 +402,7 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-y
</div> </div>
@elseif (!$this->detectedPortInfo['matches']) @elseif (!$this->detectedPortInfo['matches'])
<div <div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800"> class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-warning-50 dark:bg-warning-900/20 text-warning-800 dark:text-warning-300 border border-warning-200 dark:border-warning-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"

View file

@ -3,7 +3,9 @@
<x-slide-over @startdatabase.window="slideOverOpen = true" closeWithX fullScreen> <x-slide-over @startdatabase.window="slideOverOpen = true" closeWithX fullScreen>
<x-slot:title>Database Startup</x-slot:title> <x-slot:title>Database Startup</x-slot:title>
<x-slot:content> <x-slot:content>
<livewire:activity-monitor header="Logs" fullHeight /> <div wire:ignore>
<livewire:activity-monitor header="Logs" fullHeight />
</div>
</x-slot:content> </x-slot:content>
</x-slide-over> </x-slide-over>
<div class="navbar-main"> <div class="navbar-main">

View file

@ -225,7 +225,9 @@ class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
<x-slide-over @databaserestore.window="slideOverOpen = true" closeWithX fullScreen> <x-slide-over @databaserestore.window="slideOverOpen = true" closeWithX fullScreen>
<x-slot:title>Database Restore Output</x-slot:title> <x-slot:title>Database Restore Output</x-slot:title>
<x-slot:content> <x-slot:content>
<livewire:activity-monitor wire:key="database-restore-{{ $resource->uuid }}" header="Logs" fullHeight /> <div wire:ignore>
<livewire:activity-monitor wire:key="database-restore-{{ $resource->uuid }}" header="Logs" fullHeight />
</div>
</x-slot:content> </x-slot:content>
</x-slide-over> </x-slide-over>
@else @else

View file

@ -71,14 +71,18 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
<div class="text-gray-600 dark:text-gray-400 text-sm"> <div class="text-gray-600 dark:text-gray-400 text-sm">
@if ($backup->latest_log) @if ($backup->latest_log)
@if (data_get($backup->latest_log, 'status') === 'running') @if (data_get($backup->latest_log, 'status') === 'running')
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}"> <span
Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }} title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
Running for
{{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
</span> </span>
@else @else
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}&#10;Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}"> <span
title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}&#10;Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }} {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }}) ({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }})
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }}
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }}
</span> </span>
@endif @endif
@if (data_get($backup->latest_log, 'status') === 'success') @if (data_get($backup->latest_log, 'status') === 'success')
@ -155,14 +159,18 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
<div class="text-gray-600 dark:text-gray-400 text-sm"> <div class="text-gray-600 dark:text-gray-400 text-sm">
@if ($backup->latest_log) @if ($backup->latest_log)
@if (data_get($backup->latest_log, 'status') === 'running') @if (data_get($backup->latest_log, 'status') === 'running')
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}"> <span
Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }} title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
Running for
{{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
</span> </span>
@else @else
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}&#10;Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}"> <span
title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}&#10;Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }} {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }}) ({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }})
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }}
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }}
</span> </span>
@endif @endif
@if (data_get($backup->latest_log, 'status') === 'success') @if (data_get($backup->latest_log, 'status') === 'success')
@ -186,7 +194,7 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
Success Rate: <span @class([ Success Rate: <span @class([
'font-medium', 'font-medium',
'text-green-600' => $successRate >= 80, 'text-green-600' => $successRate >= 80,
'text-yellow-600' => $successRate >= 50 && $successRate < 80, 'text-warning-600' => $successRate >= 50 && $successRate < 80,
'text-red-600' => $successRate < 50, 'text-red-600' => $successRate < 50,
])>{{ $successRate }}%</span> ])>{{ $successRate }}%</span>
({{ $successCount }}/{{ $totalCount }}) ({{ $successCount }}/{{ $totalCount }})

View file

@ -13,7 +13,7 @@
<div class="subtitle">All your projects are here.</div> <div class="subtitle">All your projects are here.</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 -mt-1"> <div class="grid grid-cols-1 gap-4 xl:grid-cols-2 -mt-1">
@foreach ($projects as $project) @foreach ($projects as $project)
<div class="relative gap-2 cursor-pointer box group"> <div class="relative gap-2 cursor-pointer coolbox group">
<a href="{{ $project->navigateTo() }}" class="absolute inset-0"></a> <a href="{{ $project->navigateTo() }}" class="absolute inset-0"></a>
<div class="flex flex-1 mx-6"> <div class="flex flex-1 mx-6">
<div class="flex flex-col justify-center flex-1"> <div class="flex flex-col justify-center flex-1">

View file

@ -7,7 +7,7 @@
<div class="flex flex-col justify-center gap-2 text-left "> <div class="flex flex-col justify-center gap-2 text-left ">
@forelse ($private_keys as $key) @forelse ($private_keys as $key)
@if ($private_key_id == $key->id) @if ($private_key_id == $key->id)
<div class="gap-2 py-4 cursor-pointer group hover:bg-coollabs bg-coolgray-100 box" <div class="gap-2 py-4 cursor-pointer group hover:bg-coollabs bg-coolgray-100 coolbox"
wire:click="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}"> wire:click="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
<div class="box-title"> <div class="box-title">
@ -20,7 +20,7 @@ class="loading loading-xs dark:text-warning loading-spinner"></span>
</div> </div>
</div> </div>
@else @else
<div class="gap-2 py-4 cursor-pointer group hover:bg-coollabs bg-coolgray-100 box" <div class="gap-2 py-4 cursor-pointer group hover:bg-coollabs bg-coolgray-100 coolbox"
wire:click="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}"> wire:click="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
<div class="box-title"> <div class="box-title">

View file

@ -21,7 +21,7 @@
<div class="flex flex-col justify-center gap-2 text-left"> <div class="flex flex-col justify-center gap-2 text-left">
@foreach ($github_apps as $ghapp) @foreach ($github_apps as $ghapp)
<div class="flex"> <div class="flex">
<div class="w-full gap-2 py-4 bg-white cursor-pointer group hover:bg-coollabs dark:bg-coolgray-200 box" <div class="w-full gap-2 py-4 bg-white cursor-pointer group hover:bg-coollabs dark:bg-coolgray-200 coolbox"
wire:click.prevent="loadRepositories({{ $ghapp->id }})" wire:click.prevent="loadRepositories({{ $ghapp->id }})"
wire:key="{{ $ghapp->id }}"> wire:key="{{ $ghapp->id }}">
<div class="flex mr-4"> <div class="flex mr-4">

View file

@ -1,18 +1,19 @@
<div x-data x-init="$wire.loadServers"> <div x-data x-init="$wire.loadServers">
<div class="flex flex-col gap-4 lg:flex-row ">
<h1>New Resource</h1>
<div class="w-full pb-4 lg:w-96 lg:pb-0">
<x-forms.select wire:model.live="selectedEnvironment">
@foreach ($environments as $environment)
<option value="{{ $environment->name }}">Environment: {{ $environment->name }}</option>
@endforeach
</x-forms.select>
</div>
</div>
<div class="pb-4">Deploy resources, like Applications, Databases, Services...</div>
<div x-data="searchResources()"> <div x-data="searchResources()">
@if ($current_step === 'type') @if ($current_step === 'type')
<div x-init="window.addEventListener('scroll', () => isSticky = window.pageYOffset > 100)" class="sticky z-10 top-10 py-2 bg-white/95 dark:bg-base/95 backdrop-blur-sm"> <div x-init="window.addEventListener('scroll', () => isSticky = window.pageYOffset > 100)"
class="sticky z-10 top-0 backdrop-blur-sm border-b border-neutral-200 dark:border-coolgray-400">
<div class="flex flex-col gap-4 lg:flex-row">
<h1>New Resource</h1>
<div class="w-full lg:w-96">
<x-forms.select wire:model.live="selectedEnvironment">
@foreach ($environments as $environment)
<option value="{{ $environment->name }}">Environment: {{ $environment->name }}</option>
@endforeach
</x-forms.select>
</div>
</div>
<div class="mb-4">Deploy resources, like Applications, Databases, Services...</div>
<div class="flex gap-2 items-start"> <div class="flex gap-2 items-start">
<input autocomplete="off" x-ref="searchInput" class="input-sticky flex-1" <input autocomplete="off" x-ref="searchInput" class="input-sticky flex-1"
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..." :class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
@ -23,25 +24,34 @@
<div x-show="loading || categories.length === 0" <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"> 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> <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"> <svg class="w-4 h-4 text-neutral-400 shrink-0" fill="none" stroke="currentColor"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
<!-- Active State --> <!-- Active State -->
<div x-show="!loading && categories.length > 0" <div x-show="!loading && categories.length > 0"
@click="openCategoryDropdown = !openCategoryDropdown; $nextTick(() => { if (openCategoryDropdown) $refs.categorySearchInput.focus() })" @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"> 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> <span class="text-sm truncate"
<svg class="w-4 h-4 transition-transform text-neutral-400 shrink-0" :class="{ 'rotate-180': openCategoryDropdown }" fill="none" x-text="selectedCategory === '' ? 'Filter by category' : selectedCategory"
stroke="currentColor" viewBox="0 0 24 24"> :class="selectedCategory === '' ? 'text-neutral-400 dark:text-neutral-600' :
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> '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> </svg>
</div> </div>
<!-- Dropdown Menu --> <!-- Dropdown Menu -->
<div x-show="openCategoryDropdown" x-transition <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"> 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"> <div
<input type="text" x-ref="categorySearchInput" x-model="categorySearch" placeholder="Search categories..." 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" 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> @click.stop>
</div> </div>
@ -51,7 +61,9 @@ 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 === '' }"> :class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === '' }">
<span class="text-sm">All Categories</span> <span class="text-sm">All Categories</span>
</div> </div>
<template x-for="category in categories.filter(cat => categorySearch === '' || cat.toLowerCase().includes(categorySearch.toLowerCase()))" :key="category"> <template
x-for="category in categories.filter(cat => categorySearch === '' || cat.toLowerCase().includes(categorySearch.toLowerCase()))"
:key="category">
<div @click="selectedCategory = category; categorySearch = ''; openCategoryDropdown = false" <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="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 }"> :class="{ 'bg-neutral-50 dark:bg-coolgray-300': selectedCategory === category }">
@ -66,109 +78,114 @@ class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200
<div x-show="loading">Loading...</div> <div x-show="loading">Loading...</div>
<div x-show="!loading" class="flex flex-col gap-4 py-4"> <div x-show="!loading" class="flex flex-col gap-4 py-4">
<h2 x-show="filteredGitBasedApplications.length > 0">Applications</h2> <h2 x-show="filteredGitBasedApplications.length > 0">Applications</h2>
<h4 x-show="filteredGitBasedApplications.length > 0">Git Based</h4> <div x-show="filteredGitBasedApplications.length > 0 || filteredDockerBasedApplications.length > 0"
<div x-show="filteredGitBasedApplications.length > 0" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-1"> <div x-show="filteredGitBasedApplications.length > 0" class="space-y-4">
<template x-for="application in filteredGitBasedApplications" :key="application.name"> <h4>Git Based</h4>
<div x-on:click='setType(application.id)' <div class="grid justify-start grid-cols-1 gap-4 text-left">
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }"> <template x-for="application in filteredGitBasedApplications" :key="application.name">
<x-resource-view> <div x-on:click='setType(application.id)'
<x-slot:title><span x-text="application.name"></span></x-slot> :class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-slot:description> <x-resource-view>
<span x-html="window.sanitizeHTML(application.description)"></span> <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>
<x-slot:logo> <x-slot:logo>
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain" <img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
:src="application.logo"> :src="application.logo">
</x-slot:logo> </x-slot:logo>
</x-resource-view> </x-resource-view>
</div>
</template>
</div> </div>
</template> </div>
</div> <div x-show="filteredDockerBasedApplications.length > 0" class="space-y-4">
<h4 x-show="filteredDockerBasedApplications.length > 0">Docker Based</h4> <h4>Docker Based</h4>
<div x-show="filteredDockerBasedApplications.length > 0" <div class="grid justify-start grid-cols-1 gap-4 text-left">
class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3"> <template x-for="application in filteredDockerBasedApplications" :key="application.name">
<template x-for="application in filteredDockerBasedApplications" :key="application.name"> <div x-on:click="setType(application.id)"
<div x-on:click="setType(application.id)" :class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }"> <x-resource-view>
<x-resource-view> <x-slot:title><span x-text="application.name"></span></x-slot>
<x-slot:title><span x-text="application.name"></span></x-slot> <x-slot:description><span x-text="application.description"></span></x-slot>
<x-slot:description><span x-text="application.description"></span></x-slot>
<x-slot:logo> <img <x-slot:logo> <img
class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain" 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> :src="application.logo"></x-slot>
</x-resource-view> </x-resource-view>
</div>
</template>
</div> </div>
</template> </div>
</div> </div>
<h2 x-show="filteredDatabases.length > 0">Databases</h2> <div x-show="filteredDatabases.length > 0" class="mt-8">
<div x-show="filteredDatabases.length > 0" <h2 class="mb-4">Databases</h2>
class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-2"> <div class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3">
<template x-for="database in filteredDatabases" :key="database.id"> <template x-for="database in filteredDatabases" :key="database.id">
<div x-on:click="setType(database.id)" <div x-on:click="setType(database.id)"
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }"> :class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view> <x-resource-view>
<x-slot:title><span x-text="database.name"></span></x-slot> <x-slot:title><span x-text="database.name"></span></x-slot>
<x-slot:description><span x-text="database.description"></span></x-slot> <x-slot:description><span x-text="database.description"></span></x-slot>
<x-slot:logo> <x-slot:logo>
<span x-show="database.logo"> <span x-show="database.logo">
<span x-html="database.logo"></span> <span x-html="database.logo"></span>
</span> </span>
</x-slot> </x-slot>
</x-resource-view> </x-resource-view>
</div> </div>
</template> </template>
</div>
</div> </div>
<div x-show="filteredServices.length > 0"> <div x-show="filteredServices.length > 0" class="mt-8">
<div class="flex items-center gap-4" x-init="loadResources"> <div class="flex items-center gap-4" x-init="loadResources">
<h2>Services</h2> <h2>Services</h2>
<x-forms.button x-on:click="loadResources">Reload List</x-forms.button> <x-forms.button x-on:click="loadResources">Reload List</x-forms.button>
</div> </div>
<div class="py-4 text-xs">Trademarks Policy: The respective trademarks mentioned here are owned by <x-callout type="info" title="Trademarks Policy" class="mt-4 mb-6">
the The respective trademarks mentioned here are owned by the respective companies, and use of them
respective does not imply any affiliation or endorsement.
companies, and use of them does not imply any affiliation or endorsement.<br>Find more services </x-callout>
<a class="dark:text-white underline" target="_blank"
href="https://coolify.io/docs/services/overview">here</a>.
</div>
<div class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-2"> <div class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3">
<template x-for="service in filteredServices" :key="service.name"> <template x-for="service in filteredServices" :key="service.name">
<div x-on:click="setType('one-click-service-' + service.name)" <div class="relative" x-on:click="setType('one-click-service-' + service.name)"
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }"> :class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view> <x-resource-view>
<x-slot:title> <x-slot:title>
<template x-if="service.name"> <template x-if="service.name">
<span x-text="service.name"></span> <span x-text="service.name"></span>
</template> </template>
</x-slot> </x-slot>
<x-slot:description> <x-slot:description>
<template x-if="service.slogan"> <template x-if="service.slogan">
<span x-text="service.slogan"></span> <span x-text="service.slogan"></span>
</template> </template>
</x-slot> </x-slot>
<x-slot:logo> <x-slot:logo>
<template x-if="service.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" <img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
:src='service.logo' :src='service.logo'
x-on:error.window="$event.target.src = service.logo_github_url" x-on:error.window="$event.target.src = service.logo_github_url"
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');" onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
x-on:error="$event.target.src = '/coolify-logo.svg'" x-on:error="$event.target.src = '/coolify-logo.svg'"
:data-fallback='service.logo_github_url' /> :data-fallback='service.logo_github_url' />
</template> </template>
</x-slot:logo> </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> </x-resource-view>
<template x-if="shouldShowDocIcon(service)">
<a :href="getDocLink(service) || coolifyDocsUrl(service.name)" target="_blank"
@click.stop @mouseenter="resolveDocLink(service)"
class="absolute top-2 right-2 p-1.5 rounded hover:bg-neutral-200 dark:hover:bg-coolgray-300 transition-colors"
:class="{ 'opacity-50': docCheckInProgress[service.name] }"
title="View documentation">
<svg class="w-4 h-4 text-neutral-600 dark:text-neutral-400" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
</template>
</div> </div>
</template> </template>
</div> </div>
@ -195,6 +212,8 @@ function searchResources() {
gitBasedApplications: [], gitBasedApplications: [],
dockerBasedApplications: [], dockerBasedApplications: [],
databases: [], databases: [],
docLinkCache: {}, // Cache resolved doc URLs: { serviceName: url | null }
docCheckInProgress: {}, // Track ongoing checks: { serviceName: boolean }
setType(type) { setType(type) {
if (this.selecting) return; if (this.selecting) return;
this.selecting = true; this.selecting = true;
@ -219,6 +238,81 @@ function searchResources() {
this.$refs.searchInput.focus(); this.$refs.searchInput.focus();
}); });
}, },
extractBaseServiceName(serviceName) {
// Convert to lowercase and replace spaces with dashes to match original format
const normalized = serviceName.toLowerCase().replace(/\s+/g, '-');
// Remove flavor suffixes: -with-*, -without-*
return normalized.replace(/-(with|without)-.+$/, '');
},
coolifyDocsUrl(serviceName) {
const baseName = this.extractBaseServiceName(serviceName);
return 'https://coolify.io/docs/services/' + baseName;
},
officialDocsUrl(service) {
return service.documentation || null;
},
async checkUrlExists(url) {
if (!url) return false;
try {
const response = await fetch(url, {
method: 'HEAD'
});
return response.ok;
} catch (e) {
// CORS error or network error - assume URL exists
return true;
}
},
async resolveDocLink(service) {
const serviceName = service.name;
// Already cached?
if (this.docLinkCache.hasOwnProperty(serviceName)) {
return this.docLinkCache[serviceName];
}
// Already checking?
if (this.docCheckInProgress[serviceName]) {
return null;
}
this.docCheckInProgress[serviceName] = true;
// 1. Try Coolify docs first
const coolifyUrl = this.coolifyDocsUrl(serviceName);
const coolifyExists = await this.checkUrlExists(coolifyUrl);
if (coolifyExists) {
this.docLinkCache[serviceName] = coolifyUrl;
this.docCheckInProgress[serviceName] = false;
return coolifyUrl;
}
// 2. Fall back to official docs
const officialUrl = this.officialDocsUrl(service);
if (officialUrl) {
const officialExists = await this.checkUrlExists(officialUrl);
if (officialExists) {
this.docLinkCache[serviceName] = officialUrl;
this.docCheckInProgress[serviceName] = false;
return officialUrl;
}
}
// 3. Both failed - cache null to hide icon
this.docLinkCache[serviceName] = null;
this.docCheckInProgress[serviceName] = false;
return null;
},
getDocLink(service) {
return this.docLinkCache[service.name];
},
shouldShowDocIcon(service) {
const cached = this.docLinkCache[service.name];
// Show icon if: not checked yet OR has a valid URL
return cached === undefined || cached !== null;
},
filterAndSort(items, isSort = true) { filterAndSort(items, isSort = true) {
const searchLower = this.search.trim().toLowerCase(); const searchLower = this.search.trim().toLowerCase();
let filtered = Object.values(items); let filtered = Object.values(items);
@ -229,9 +323,10 @@ function searchResources() {
filtered = filtered.filter(item => { filtered = filtered.filter(item => {
if (!item.category) return false; if (!item.category) return false;
// Handle comma-separated categories // Handle comma-separated categories
const categories = item.category.includes(',') const categories = item.category.includes(',') ?
? item.category.split(',').map(c => c.trim().toLowerCase()) item.category.split(',').map(c => c.trim().toLowerCase()) : [item.category
: [item.category.toLowerCase()]; .toLowerCase()
];
return categories.includes(selectedCategoryLower); return categories.includes(selectedCategoryLower);
}); });
} }
@ -295,7 +390,7 @@ function searchResources() {
</a> </div> </a> </div>
@else @else
@forelse($servers as $server) @forelse($servers as $server)
<div class="w-full box group" wire:click="setServer({{ $server }})"> <div class="w-full coolbox group" wire:click="setServer({{ $server }})">
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
<div class="box-title"> <div class="box-title">
{{ $server->name }} {{ $server->name }}
@ -308,7 +403,8 @@ function searchResources() {
@empty @empty
<div> <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 Go to servers page
</a></div> </a></div>
</div> </div>
@ -324,7 +420,7 @@ function searchResources() {
<div class="flex flex-col justify-center gap-4 text-left xl:flex-row xl:flex-wrap"> <div class="flex flex-col justify-center gap-4 text-left xl:flex-row xl:flex-wrap">
@if ($server->isSwarm()) @if ($server->isSwarm())
@foreach ($swarmDockers as $swarmDocker) @foreach ($swarmDockers as $swarmDocker)
<div class="w-full box group" wire:click="setDestination('{{ $swarmDocker->uuid }}')"> <div class="w-full coolbox group" wire:click="setDestination('{{ $swarmDocker->uuid }}')">
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
<div class="font-bold dark:group-hover:text-white"> <div class="font-bold dark:group-hover:text-white">
Swarm Docker <span class="text-xs">({{ $swarmDocker->name }})</span> Swarm Docker <span class="text-xs">({{ $swarmDocker->name }})</span>
@ -334,7 +430,7 @@ function searchResources() {
@endforeach @endforeach
@else @else
@foreach ($standaloneDockers as $standaloneDocker) @foreach ($standaloneDockers as $standaloneDocker)
<div class="w-full box group" wire:click="setDestination('{{ $standaloneDocker->uuid }}')"> <div class="w-full coolbox group" wire:click="setDestination('{{ $standaloneDocker->uuid }}')">
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
<div class="box-title"> <div class="box-title">
Standalone Docker <span class="text-xs">({{ $standaloneDocker->name }})</span> Standalone Docker <span class="text-xs">({{ $standaloneDocker->name }})</span>
@ -368,7 +464,8 @@ function searchResources() {
<div class="flex items-center px-2" title="Read the documentation."> <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" <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 Documentation
</a> </a>
</div> </div>
@ -386,7 +483,8 @@ function searchResources() {
<div class="flex-1"></div> <div class="flex-1"></div>
<div class="flex items-center px-2" title="Read the documentation."> <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" <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 Documentation
</a> </a>
</div> </div>
@ -424,7 +522,8 @@ function searchResources() {
<div class="flex items-center px-2" title="Read the documentation."> <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" <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 Documentation
</a> </a>
</div> </div>

View file

@ -54,7 +54,7 @@ class="button">+
@if ($environment->isEmpty()) @if ($environment->isEmpty())
@can('createAnyResource') @can('createAnyResource')
<a href="{{ route('project.resource.create', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => data_get($environment, 'uuid')]) }}" <a href="{{ route('project.resource.create', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => data_get($environment, 'uuid')]) }}"
class="items-center justify-center box">+ Add Resource</a> class="items-center justify-center coolbox">+ Add Resource</a>
@else @else
<div <div
class="flex flex-col items-center justify-center p-8 text-center border border-dashed border-neutral-300 dark:border-coolgray-300 rounded-lg"> class="flex flex-col items-center justify-center p-8 text-center border border-dashed border-neutral-300 dark:border-coolgray-300 rounded-lg">
@ -94,7 +94,7 @@ class="font-semibold" x-text="search"></span>".</p>
class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3"> class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<template x-for="item in filteredApplications" :key="item.uuid"> <template x-for="item in filteredApplications" :key="item.uuid">
<span> <span>
<a class="h-24 box group" :href="item.hrefLink"> <a class="h-24 coolbox group" :href="item.hrefLink">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="flex gap-2 px-4"> <div class="flex gap-2 px-4">
<div class="pb-2 truncate box-title" x-text="item.name"></div> <div class="pb-2 truncate box-title" x-text="item.name"></div>
@ -143,7 +143,7 @@ class="flex flex-wrap gap-1 pt-1 dark:group-hover:text-white group-hover:text-bl
class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3"> class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<template x-for="item in filteredDatabases" :key="item.uuid"> <template x-for="item in filteredDatabases" :key="item.uuid">
<span> <span>
<a class="h-24 box group" :href="item.hrefLink"> <a class="h-24 coolbox group" :href="item.hrefLink">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="flex gap-2 px-4"> <div class="flex gap-2 px-4">
<div class="pb-2 truncate box-title" x-text="item.name"></div> <div class="pb-2 truncate box-title" x-text="item.name"></div>
@ -192,7 +192,7 @@ class="flex flex-wrap gap-1 pt-1 dark:group-hover:text-white group-hover:text-bl
class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3"> class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<template x-for="item in filteredServices" :key="item.uuid"> <template x-for="item in filteredServices" :key="item.uuid">
<span> <span>
<a class="h-24 box group" :href="item.hrefLink"> <a class="h-24 coolbox group" :href="item.hrefLink">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="flex gap-2 px-4"> <div class="flex gap-2 px-4">
<div class="pb-2 truncate box-title" x-text="item.name"></div> <div class="pb-2 truncate box-title" x-text="item.name"></div>

View file

@ -90,7 +90,7 @@ class="absolute bg-error -top-1 -left-1 badge "></div>
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
@foreach ($networks as $network) @foreach ($networks as $network)
<div wire:click="addServer('{{ $network->id }}','{{ data_get($network, 'server.id') }}')" <div wire:click="addServer('{{ $network->id }}','{{ data_get($network, 'server.id') }}')"
class="relative flex flex-col dark:text-white box group"> class="relative flex flex-col dark:text-white coolbox group">
<div> <div>
<div class="box-title"> <div class="box-title">
Server: {{ data_get($network, 'server.name') }} Server: {{ data_get($network, 'server.name') }}

View file

@ -102,9 +102,64 @@
</div> </div>
</div> </div>
@if ($outputs) @if ($outputs)
<pre id="logs" class="font-mono whitespace-pre-wrap break-all max-w-full">{{ $outputs }}</pre> <div id="logs" class="font-mono text-sm">
@foreach (explode("\n", trim($outputs)) as $line)
@if (!empty(trim($line)))
@php
$lowerLine = strtolower($line);
$isError =
str_contains($lowerLine, 'error') ||
str_contains($lowerLine, 'err') ||
str_contains($lowerLine, 'failed') ||
str_contains($lowerLine, 'exception');
$isWarning =
str_contains($lowerLine, 'warn') ||
str_contains($lowerLine, 'warning') ||
str_contains($lowerLine, 'wrn');
$isDebug =
str_contains($lowerLine, 'debug') ||
str_contains($lowerLine, 'dbg') ||
str_contains($lowerLine, 'trace');
$barColor = $isError
? 'bg-red-500 dark:bg-red-400'
: ($isWarning
? 'bg-warning-500 dark:bg-warning-400'
: ($isDebug
? 'bg-purple-500 dark:bg-purple-400'
: 'bg-blue-500 dark:bg-blue-400'));
$bgColor = $isError
? 'bg-red-50/50 dark:bg-red-900/20 hover:bg-red-100/50 dark:hover:bg-red-800/30'
: ($isWarning
? 'bg-warning-50/50 dark:bg-warning-900/20 hover:bg-warning-100/50 dark:hover:bg-warning-800/30'
: ($isDebug
? 'bg-purple-50/50 dark:bg-purple-900/20 hover:bg-purple-100/50 dark:hover:bg-purple-800/30'
: 'bg-blue-50/50 dark:bg-blue-900/20 hover:bg-blue-100/50 dark:hover:bg-blue-800/30'));
// Check for timestamp at the beginning (ISO 8601 format)
$timestampPattern = '/^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?)\s+/';
$hasTimestamp = preg_match($timestampPattern, $line, $matches);
$timestamp = $hasTimestamp ? $matches[1] : null;
$logContent = $hasTimestamp ? preg_replace($timestampPattern, '', $line) : $line;
@endphp
<div class="flex items-start gap-2 py-1 px-2 rounded-sm">
<div class="w-1 {{ $barColor }} rounded-full flex-shrink-0 self-stretch"></div>
<div class="flex-1 {{ $bgColor }} py-1 px-2 -mx-2 rounded-sm">
@if ($hasTimestamp)
<span
class="text-xs text-gray-500 dark:text-gray-400 font-mono mr-2">{{ $timestamp }}</span>
<span class="whitespace-pre-wrap break-all">{{ $logContent }}</span>
@else
<span class="whitespace-pre-wrap break-all">{{ $line }}</span>
@endif
</div>
</div>
@endif
@endforeach
</div>
@else @else
<pre id="logs" class="font-mono whitespace-pre-wrap break-all max-w-full">Refresh to get the logs...</pre> <div id="logs" class="font-mono text-sm py-4 px-2 text-gray-500 dark:text-gray-400">
Refresh to get the logs...
</div>
@endif @endif
</div> </div>
</div> </div>

View file

@ -14,7 +14,7 @@
<div class="flex flex-col flex-wrap gap-2 pt-4"> <div class="flex flex-col flex-wrap gap-2 pt-4">
@forelse($resource->scheduled_tasks as $task) @forelse($resource->scheduled_tasks as $task)
@if ($resource->type() == 'application') @if ($resource->type() == 'application')
<a class="box" <a class="coolbox"
href="{{ route('project.application.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}"> href="{{ route('project.application.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}">
<span class="flex flex-col"> <span class="flex flex-col">
<span class="text-lg font-bold">{{ $task->name }} <span class="text-lg font-bold">{{ $task->name }}
@ -29,7 +29,7 @@
</span> </span>
</a> </a>
@elseif ($resource->type() == 'service') @elseif ($resource->type() == 'service')
<a class="box" <a class="coolbox"
href="{{ route('project.service.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}"> href="{{ route('project.service.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}">
<span class="flex flex-col"> <span class="flex flex-col">
<span class="text-lg font-bold">{{ $task->name }} <span class="text-lg font-bold">{{ $task->name }}

View file

@ -21,7 +21,7 @@
<div class="text-xs truncate subtitle lg:text-sm">{{ $project->name }}.</div> <div class="text-xs truncate subtitle lg:text-sm">{{ $project->name }}.</div>
<div class="grid gap-2 lg:grid-cols-2"> <div class="grid gap-2 lg:grid-cols-2">
@forelse ($project->environments->sortBy('created_at') as $environment) @forelse ($project->environments->sortBy('created_at') as $environment)
<div class="gap-2 box group"> <div class="gap-2 coolbox group">
<div class="flex flex-1 mx-6"> <div class="flex flex-1 mx-6">
<a class="flex flex-col justify-center flex-1" <a class="flex flex-col justify-center flex-1"
href="{{ route('project.resource.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}"> href="{{ route('project.resource.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}">

View file

@ -16,7 +16,7 @@
@forelse ($privateKeys as $key) @forelse ($privateKeys as $key)
@can('view', $key) @can('view', $key)
{{-- Admin/Owner: Clickable link --}} {{-- Admin/Owner: Clickable link --}}
<a class="box group" <a class="coolbox group"
href="{{ route('security.private-key.show', ['private_key_uuid' => data_get($key, 'uuid')]) }}"> href="{{ route('security.private-key.show', ['private_key_uuid' => data_get($key, 'uuid')]) }}">
<div class="flex flex-col justify-center mx-6"> <div class="flex flex-col justify-center mx-6">
<div class="box-title"> <div class="box-title">
@ -26,14 +26,14 @@
{{ $key->description }} {{ $key->description }}
@if (!$key->isInUse()) @if (!$key->isInUse())
<span <span
class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-yellow-400 text-black">Unused</span> class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-warning-400 text-black">Unused</span>
@endif @endif
</div> </div>
</div> </div>
</a> </a>
@else @else
{{-- Member: Visible but not clickable --}} {{-- Member: Visible but not clickable --}}
<div class="box opacity-60 cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent" title="You don't have permission to view this private key"> <div class="coolbox opacity-60 !cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent" title="You don't have permission to view this private key">
<div class="flex flex-col justify-center mx-6"> <div class="flex flex-col justify-center mx-6">
<div class="box-title"> <div class="box-title">
{{ data_get($key, 'name') }} {{ data_get($key, 'name') }}
@ -43,7 +43,7 @@ class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-ye
{{ $key->description }} {{ $key->description }}
@if (!$key->isInUse()) @if (!$key->isInUse())
<span <span
class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-yellow-400 text-black">Unused</span> class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-warning-400 text-black">Unused</span>
@endif @endif
</div> </div>
</div> </div>

View file

@ -74,7 +74,7 @@ class="underline">documentation</a>.
<div class="flex flex-col pb-2"> <div class="flex flex-col pb-2">
<h3>Automated </h3> <h3>Automated </h3>
<a href="https://coolify.io/docs/knowledge-base/cloudflare/tunnels/server-ssh" target="_blank" <a href="https://coolify.io/docs/knowledge-base/cloudflare/tunnels/server-ssh" target="_blank"
class="text-xs underline hover:text-yellow-600 dark:hover:text-yellow-200">Docs<x-external-link /></a> class="text-xs underline hover:text-warning-600 dark:hover:text-warning-200">Docs<x-external-link /></a>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<x-slide-over @automated.window="slideOverOpen = true" fullScreen> <x-slide-over @automated.window="slideOverOpen = true" fullScreen>

View file

@ -4,7 +4,7 @@
<div> <div>
<x-modal-input title="Connect a Hetzner Server"> <x-modal-input title="Connect a Hetzner Server">
<x-slot:content> <x-slot:content>
<div class="relative gap-2 cursor-pointer box group"> <div class="relative gap-2 cursor-pointer coolbox group">
<div class="flex items-center gap-4 mx-6"> <div class="flex items-center gap-4 mx-6">
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" /> <rect width="200" height="200" fill="#D50C2D" rx="8" />

View file

@ -15,7 +15,7 @@
@forelse ($servers as $server) @forelse ($servers as $server)
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}" <a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}"
@class([ @class([
'gap-2 border cursor-pointer box group', 'gap-2 border cursor-pointer coolbox group',
'border-red-500' => 'border-red-500' =>
!$server->settings->is_reachable || $server->settings->force_disabled, !$server->settings->is_reachable || $server->settings->force_disabled,
])> ])>

View file

@ -111,7 +111,7 @@
<x-highlighted text="*" /> <x-highlighted text="*" />
</label> </label>
<div <div
class="p-4 border border-yellow-500 dark:border-yellow-600 rounded bg-yellow-50 dark:bg-yellow-900/10"> class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-50 dark:bg-warning-900/10">
<p class="text-sm mb-3 text-neutral-700 dark:text-neutral-300"> <p class="text-sm mb-3 text-neutral-700 dark:text-neutral-300">
No private keys found. You need to create a private key to continue. No private keys found. You need to create a private key to continue.
</p> </p>

View file

@ -77,9 +77,9 @@
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value) @if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
<button type="button" x-show="traefikWarningsDismissed" <button type="button" x-show="traefikWarningsDismissed"
@click="traefikWarningsDismissed = false; localStorage.removeItem('callout-dismissed-traefik-warnings-{{ $server->id }}')" @click="traefikWarningsDismissed = false; localStorage.removeItem('callout-dismissed-traefik-warnings-{{ $server->id }}')"
class="p-1.5 rounded hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors" class="p-1.5 rounded hover:bg-warning-100 dark:hover:bg-warning-900/30 transition-colors"
title="Show Traefik warnings"> title="Show Traefik warnings">
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"> <svg class="w-4 h-4 text-warning-600 dark:text-warning-400" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16"></path> <path fill="currentColor" d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16"></path>
</svg> </svg>
</button> </button>
@ -170,13 +170,13 @@ class="underline text-white">Traefik changelog</a> to understand breaking change
<div class="subtitle">Select a proxy you would like to use on this server.</div> <div class="subtitle">Select a proxy you would like to use on this server.</div>
@can('update', $server) @can('update', $server)
<div class="grid gap-4"> <div class="grid gap-4">
<x-forms.button class="box" wire:click="selectProxy('NONE')"> <x-forms.button class="coolbox" wire:click="selectProxy('NONE')">
Custom (None) Custom (None)
</x-forms.button> </x-forms.button>
<x-forms.button class="box" wire:click="selectProxy('TRAEFIK')"> <x-forms.button class="coolbox" wire:click="selectProxy('TRAEFIK')">
Traefik Traefik
</x-forms.button> </x-forms.button>
<x-forms.button class="box" wire:click="selectProxy('CADDY')"> <x-forms.button class="coolbox" wire:click="selectProxy('CADDY')">
Caddy Caddy
</x-forms.button> </x-forms.button>
{{-- <x-forms.button disabled class="box"> {{-- <x-forms.button disabled class="box">

View file

@ -24,7 +24,7 @@
@if ($hetznerServerStatus) @if ($hetznerServerStatus)
<span class="pl-1.5"> <span class="pl-1.5">
@if (in_array($hetznerServerStatus, ['starting', 'initializing'])) @if (in_array($hetznerServerStatus, ['starting', 'initializing']))
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-yellow-500" <svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" <circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle> stroke="currentColor" stroke-width="4"></circle>
@ -42,7 +42,7 @@
</span> </span>
@else @else
<span class="pl-1.5"> <span class="pl-1.5">
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-yellow-500" <svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" <circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle> stroke="currentColor" stroke-width="4"></circle>
@ -80,7 +80,7 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
@endif @endif
@if ($isValidating) @if ($isValidating)
<div <div
class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"> class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400">
<svg class="inline animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" <svg class="inline animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"

View file

@ -11,7 +11,7 @@
<h2>Project: {{ data_get($project, 'name') }}</h2> <h2>Project: {{ data_get($project, 'name') }}</h2>
<div class="pt-0 pb-3">{{ data_get($project, 'description') }}</div> <div class="pt-0 pb-3">{{ data_get($project, 'description') }}</div>
@forelse ($project->environments as $environment) @forelse ($project->environments as $environment)
<a class="box group" <a class="coolbox group"
href="{{ route('shared-variables.environment.show', [ href="{{ route('shared-variables.environment.show', [
'project_uuid' => $project->uuid, 'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid, 'environment_uuid' => $environment->uuid,

View file

@ -8,19 +8,19 @@
<div class="subtitle">Set Team / Project / Environment wide variables.</div> <div class="subtitle">Set Team / Project / Environment wide variables.</div>
<div class="flex flex-col gap-2 -mt-1"> <div class="flex flex-col gap-2 -mt-1">
<a class="box group" href="{{ route('shared-variables.team.index') }}"> <a class="coolbox group" href="{{ route('shared-variables.team.index') }}">
<div class="flex flex-col justify-center mx-6"> <div class="flex flex-col justify-center mx-6">
<div class="box-title">Team wide</div> <div class="box-title">Team wide</div>
<div class="box-description">Usable for all resources in a team.</div> <div class="box-description">Usable for all resources in a team.</div>
</div> </div>
</a> </a>
<a class="box group" href="{{ route('shared-variables.project.index') }}"> <a class="coolbox group" href="{{ route('shared-variables.project.index') }}">
<div class="flex flex-col justify-center mx-6"> <div class="flex flex-col justify-center mx-6">
<div class="box-title">Project wide</div> <div class="box-title">Project wide</div>
<div class="box-description">Usable for all resources in a project.</div> <div class="box-description">Usable for all resources in a project.</div>
</div> </div>
</a> </a>
<a class="box group" href="{{ route('shared-variables.environment.index') }}"> <a class="coolbox group" href="{{ route('shared-variables.environment.index') }}">
<div class="flex flex-col justify-center mx-6"> <div class="flex flex-col justify-center mx-6">
<div class="box-title">Environment wide</div> <div class="box-title">Environment wide</div>
<div class="box-description">Usable for all resources in an environment.</div> <div class="box-description">Usable for all resources in an environment.</div>

View file

@ -8,7 +8,7 @@
<div class="subtitle">List of your projects.</div> <div class="subtitle">List of your projects.</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@forelse ($projects as $project) @forelse ($projects as $project)
<a class="box group" <a class="coolbox group"
href="{{ route('shared-variables.project.show', ['project_uuid' => data_get($project, 'uuid')]) }}"> href="{{ route('shared-variables.project.show', ['project_uuid' => data_get($project, 'uuid')]) }}">
<div class="flex flex-col justify-center mx-6 "> <div class="flex flex-col justify-center mx-6 ">
<div class="box-title">{{ $project->name }}</div> <div class="box-title">{{ $project->name }}</div>

View file

@ -35,7 +35,7 @@
</svg> </svg>
<span>You must complete this step before you can use this source!</span> <span>You must complete this step before you can use this source!</span>
</div> </div>
<a class="items-center justify-center box" href="{{ getInstallationPath($github_app) }}"> <a class="items-center justify-center coolbox" href="{{ getInstallationPath($github_app) }}">
Install Repositories on GitHub Install Repositories on GitHub
</a> </a>
@else @else

View file

@ -13,7 +13,7 @@
<div class="subtitle">S3 storages for backups.</div> <div class="subtitle">S3 storages for backups.</div>
<div class="grid gap-4 lg:grid-cols-2 -mt-1"> <div class="grid gap-4 lg:grid-cols-2 -mt-1">
@forelse ($s3 as $storage) @forelse ($s3 as $storage)
<a href="/storages/{{ $storage->uuid }}" @class(['gap-2 border cursor-pointer box group'])> <a href="/storages/{{ $storage->uuid }}" @class(['gap-2 border cursor-pointer coolbox group'])>
<div class="flex flex-col justify-center mx-6"> <div class="flex flex-col justify-center mx-6">
<div class="box-title"> <div class="box-title">
{{ $storage->name }} {{ $storage->name }}

View file

@ -6,7 +6,7 @@
<a href="{{ data_get($deployment, 'deployment_url') }}" @class([ <a href="{{ data_get($deployment, 'deployment_url') }}" @class([
'box-without-bg-without-border dark:bg-coolgray-100 bg-white gap-2 cursor-pointer group border-l-2', 'box-without-bg-without-border dark:bg-coolgray-100 bg-white gap-2 cursor-pointer group border-l-2',
'dark:border-coolgray-300' => data_get($deployment, 'status') === 'queued', 'dark:border-coolgray-300' => data_get($deployment, 'status') === 'queued',
'dark:border-yellow-500' => 'dark:border-warning-500' =>
data_get($deployment, 'status') === 'in_progress', data_get($deployment, 'status') === 'in_progress',
])> ])>
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">

View file

@ -8,7 +8,7 @@
<div class="flex flex-wrap gap-2 "> <div class="flex flex-wrap gap-2 ">
@forelse ($tags as $oneTag) @forelse ($tags as $oneTag)
<a :class="{{ $tag?->id == $oneTag->id }} && 'dark:bg-coollabs'" <a :class="{{ $tag?->id == $oneTag->id }} && 'dark:bg-coollabs'"
class="min-w-32 box-without-bg dark:bg-coolgray-100 dark:text-white font-bold dark:hover:bg-coollabs-100 flex justify-center items-center" class="min-w-32 coolbox dark:text-white font-bold flex justify-center items-center"
href="{{ route('tags.show', ['tagName' => $oneTag->name]) }}">{{ data_get_str($oneTag, 'name')->limit(30) }}</a> href="{{ route('tags.show', ['tagName' => $oneTag->name]) }}">{{ data_get_str($oneTag, 'name')->limit(30) }}</a>
@empty @empty
<div>No tags yet defined yet. Go to a resource and add a tag there.</div> <div>No tags yet defined yet. Go to a resource and add a tag there.</div>
@ -34,7 +34,7 @@ class="min-w-32 box-without-bg dark:bg-coolgray-100 dark:text-white font-bold da
<div class="grid grid-cols-1 gap-2 pt-4 lg:grid-cols-2 xl:grid-cols-3"> <div class="grid grid-cols-1 gap-2 pt-4 lg:grid-cols-2 xl:grid-cols-3">
@if (isset($applications) && count($applications) > 0) @if (isset($applications) && count($applications) > 0)
@foreach ($applications as $application) @foreach ($applications as $application)
<a href="{{ $application->link() }}" class="box group"> <a href="{{ $application->link() }}" class="coolbox group">
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
<div class="box-title"> <div class="box-title">
{{ $application->project()->name }}/{{ $application->environment->name }} {{ $application->project()->name }}/{{ $application->environment->name }}
@ -47,7 +47,7 @@ class="min-w-32 box-without-bg dark:bg-coolgray-100 dark:text-white font-bold da
@endif @endif
@if (isset($services) && count($services) > 0) @if (isset($services) && count($services) > 0)
@foreach ($services as $service) @foreach ($services as $service)
<a href="{{ $service->link() }}" class="flex flex-col box group"> <a href="{{ $service->link() }}" class="flex flex-col coolbox group">
<div class="flex flex-col"> <div class="flex flex-col">
<div class="box-title"> <div class="box-title">
{{ $service->project()->name }}/{{ $service->environment->name }} {{ $service->project()->name }}/{{ $service->environment->name }}
@ -71,9 +71,9 @@ class="min-w-32 box-without-bg dark:bg-coolgray-100 dark:text-white font-bold da
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
@foreach ($deployments as $deployment) @foreach ($deployments as $deployment)
<a href="{{ data_get($deployment, 'deployment_url') }}" @class([ <a href="{{ data_get($deployment, 'deployment_url') }}" @class([
'gap-2 cursor-pointer box group border-l-2 border-dotted', 'gap-2 cursor-pointer coolbox group border-l-2 border-dotted',
'dark:border-coolgray-300' => data_get($deployment, 'status') === 'queued', 'dark:border-coolgray-300' => data_get($deployment, 'status') === 'queued',
'border-yellow-500' => data_get($deployment, 'status') === 'in_progress', 'border-warning-500' => data_get($deployment, 'status') === 'in_progress',
])> ])>
<div class="flex flex-col mx-6"> <div class="flex flex-col mx-6">
<div class="font-bold dark:text-white"> <div class="font-bold dark:text-white">

View file

@ -93,6 +93,10 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">Cancel
modalOpen: false, modalOpen: false,
showProgress: false, showProgress: false,
currentStatus: '', currentStatus: '',
checkHealthInterval: null,
checkIfIamDeadInterval: null,
healthCheckAttempts: 0,
startTime: null,
confirmed() { confirmed() {
this.showProgress = true; this.showProgress = true;
this.$wire.$call('upgrade') this.$wire.$call('upgrade')
@ -102,43 +106,78 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">Cancel
event.returnValue = ''; event.returnValue = '';
}); });
}, },
getReviveStatusMessage(elapsedMinutes, attempts) {
if (elapsedMinutes === 0) {
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
} else if (elapsedMinutes < 2) {
return `Waiting for Coolify to come back online... (${elapsedMinutes} minute${elapsedMinutes !== 1 ? 's' : ''} elapsed)`;
} else if (elapsedMinutes < 5) {
return `Update in progress, this may take several minutes... (${elapsedMinutes} minutes elapsed)`;
} else if (elapsedMinutes < 10) {
return `Large updates can take 10+ minutes. Please be patient... (${elapsedMinutes} minutes elapsed)`;
} else {
return `Still updating. If this takes longer than 15 minutes, please check server logs... (${elapsedMinutes} minutes elapsed)`;
}
},
revive() { revive() {
if (checkHealthInterval) return true; if (this.checkHealthInterval) return true;
this.healthCheckAttempts = 0;
this.startTime = Date.now();
console.log('Checking server\'s health...') console.log('Checking server\'s health...')
checkHealthInterval = setInterval(() => { this.checkHealthInterval = setInterval(() => {
this.healthCheckAttempts++;
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
fetch('/api/health') fetch('/api/health')
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {
this.currentStatus = this.currentStatus =
'Coolify is back online. Reloading this page (you can manually reload if its not done automatically)...'; 'Coolify is back online. Reloading this page in 5 seconds...';
if (checkHealthInterval) clearInterval( if (this.checkHealthInterval) {
checkHealthInterval); clearInterval(this.checkHealthInterval);
this.checkHealthInterval = null;
}
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 5000) }, 5000)
} else { } else {
this.currentStatus = this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
"Waiting for Coolify to come back from the dead..." .healthCheckAttempts);
} }
}) })
.catch(error => {
console.error('Health check failed:', error);
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
.healthCheckAttempts);
});
}, 2000); }, 2000);
}, },
upgrade() { upgrade() {
if (checkIfIamDeadInterval || this.$wire.showProgress) return true; if (this.checkIfIamDeadInterval || this.showProgress) return true;
this.currentStatus = 'Pulling new images and updating Coolify.'; this.currentStatus = 'Update in progress. Pulling new images and preparing to restart Coolify...';
checkIfIamDeadInterval = setInterval(() => { this.checkIfIamDeadInterval = setInterval(() => {
fetch('/api/health') fetch('/api/health')
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {
this.currentStatus = "Waiting for the update process..."
} else {
this.currentStatus = this.currentStatus =
"Update done, restarting Coolify & waiting until it is revived!" "Update in progress. Pulling new images and preparing to restart Coolify..."
if (checkIfIamDeadInterval) clearInterval( } else {
checkIfIamDeadInterval); this.currentStatus = "Coolify is restarting with the new version..."
if (this.checkIfIamDeadInterval) {
clearInterval(this.checkIfIamDeadInterval);
this.checkIfIamDeadInterval = null;
}
this.revive(); this.revive();
} }
}) })
.catch(error => {
console.error('Health check failed:', error);
this.currentStatus = "Coolify is restarting with the new version..."
if (this.checkIfIamDeadInterval) {
clearInterval(this.checkIfIamDeadInterval);
this.checkIfIamDeadInterval = null;
}
this.revive();
});
}, 2000); }, 2000);
} }

View file

@ -14,7 +14,7 @@
<div class="grid gap-4 lg:grid-cols-2 -mt-1"> <div class="grid gap-4 lg:grid-cols-2 -mt-1">
@forelse ($sources as $source) @forelse ($sources as $source)
@if ($source->getMorphClass() === 'App\Models\GithubApp') @if ($source->getMorphClass() === 'App\Models\GithubApp')
<a class="flex gap-2 text-center hover:no-underline box group" <a class="flex gap-2 text-center hover:no-underline coolbox group"
href="{{ route('source.github.show', ['github_app_uuid' => data_get($source, 'uuid')]) }}"> href="{{ route('source.github.show', ['github_app_uuid' => data_get($source, 'uuid')]) }}">
{{-- <x-git-icon class="dark:text-white w-8 h-8 mt-1" git="{{ $source->getMorphClass() }}" /> --}} {{-- <x-git-icon class="dark:text-white w-8 h-8 mt-1" git="{{ $source->getMorphClass() }}" /> --}}
<div class="text-left dark:group-hover:text-white flex flex-col justify-center mx-6"> <div class="text-left dark:group-hover:text-white flex flex-col justify-center mx-6">

View file

@ -0,0 +1,186 @@
<?php
use App\Jobs\CheckForUpdatesJob;
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
Cache::flush();
// Mock InstanceSettings
$this->settings = Mockery::mock(InstanceSettings::class);
$this->settings->shouldReceive('update')->andReturn(true);
});
afterEach(function () {
Mockery::close();
});
it('has correct job configuration', function () {
$job = new CheckForUpdatesJob;
$interfaces = class_implements($job);
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class);
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldBeEncrypted::class);
});
it('uses max of CDN and cache versions', function () {
// CDN has older version
Http::fake([
'*' => Http::response([
'coolify' => ['v4' => ['version' => '4.0.0']],
'traefik' => ['v3.5' => '3.5.6'],
], 200),
]);
// Cache has newer version
File::shouldReceive('exists')
->with(base_path('versions.json'))
->andReturn(true);
File::shouldReceive('get')
->with(base_path('versions.json'))
->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.10']]]));
File::shouldReceive('put')
->once()
->with(base_path('versions.json'), Mockery::on(function ($json) {
$data = json_decode($json, true);
// Should use cached version (4.0.10), not CDN version (4.0.0)
return $data['coolify']['v4']['version'] === '4.0.10';
}));
Cache::shouldReceive('forget')->once();
config(['constants.coolify.version' => '4.0.5']);
// Mock instanceSettings function
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
$job = new CheckForUpdatesJob;
$job->handle();
});
it('never downgrades from current running version', function () {
// CDN has older version
Http::fake([
'*' => Http::response([
'coolify' => ['v4' => ['version' => '4.0.0']],
'traefik' => ['v3.5' => '3.5.6'],
], 200),
]);
// Cache also has older version
File::shouldReceive('exists')
->with(base_path('versions.json'))
->andReturn(true);
File::shouldReceive('get')
->with(base_path('versions.json'))
->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.5']]]));
File::shouldReceive('put')
->once()
->with(base_path('versions.json'), Mockery::on(function ($json) {
$data = json_decode($json, true);
// Should use running version (4.0.10), not CDN (4.0.0) or cache (4.0.5)
return $data['coolify']['v4']['version'] === '4.0.10';
}));
Cache::shouldReceive('forget')->once();
// Running version is newest
config(['constants.coolify.version' => '4.0.10']);
\Illuminate\Support\Facades\Log::shouldReceive('warning')
->once()
->with('Version downgrade prevented in CheckForUpdatesJob', Mockery::type('array'));
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
$job = new CheckForUpdatesJob;
$job->handle();
});
it('uses data_set for safe version mutation', function () {
Http::fake([
'*' => Http::response([
'coolify' => ['v4' => ['version' => '4.0.10']],
], 200),
]);
File::shouldReceive('exists')->andReturn(false);
File::shouldReceive('put')->once();
Cache::shouldReceive('forget')->once();
config(['constants.coolify.version' => '4.0.5']);
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
$job = new CheckForUpdatesJob;
// Should not throw even if structure is unexpected
// data_set() handles nested path creation
$job->handle();
})->skip('Needs better mock setup for instanceSettings');
it('preserves other component versions when preventing Coolify downgrade', function () {
// CDN has older Coolify but newer Traefik
Http::fake([
'*' => Http::response([
'coolify' => ['v4' => ['version' => '4.0.0']],
'traefik' => ['v3.6' => '3.6.2'],
'sentinel' => ['version' => '1.0.5'],
], 200),
]);
File::shouldReceive('exists')->andReturn(true);
File::shouldReceive('get')
->andReturn(json_encode([
'coolify' => ['v4' => ['version' => '4.0.5']],
'traefik' => ['v3.5' => '3.5.6'],
]));
File::shouldReceive('put')
->once()
->with(base_path('versions.json'), Mockery::on(function ($json) {
$data = json_decode($json, true);
// Coolify should use running version
expect($data['coolify']['v4']['version'])->toBe('4.0.10');
// Traefik should use CDN version (newer)
expect($data['traefik']['v3.6'])->toBe('3.6.2');
// Sentinel should use CDN version
expect($data['sentinel']['version'])->toBe('1.0.5');
return true;
}));
Cache::shouldReceive('forget')->once();
config(['constants.coolify.version' => '4.0.10']);
\Illuminate\Support\Facades\Log::shouldReceive('warning')
->once()
->with('CDN served older Coolify version than cache', Mockery::type('array'));
\Illuminate\Support\Facades\Log::shouldReceive('warning')
->once()
->with('Version downgrade prevented in CheckForUpdatesJob', Mockery::type('array'));
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
$job = new CheckForUpdatesJob;
$job->handle();
});

View file

@ -153,3 +153,39 @@
expect($result)->toBeTrue(); expect($result)->toBeTrue();
}); });
it('identifies default as predefined network', function () {
expect(isDockerPredefinedNetwork('default'))->toBeTrue();
});
it('identifies host as predefined network', function () {
expect(isDockerPredefinedNetwork('host'))->toBeTrue();
});
it('identifies coolify as not predefined network', function () {
expect(isDockerPredefinedNetwork('coolify'))->toBeFalse();
});
it('identifies coolify-overlay as not predefined network', function () {
expect(isDockerPredefinedNetwork('coolify-overlay'))->toBeFalse();
});
it('identifies custom networks as not predefined', function () {
$customNetworks = ['my-network', 'app-network', 'custom-123'];
foreach ($customNetworks as $network) {
expect(isDockerPredefinedNetwork($network))->toBeFalse();
}
});
it('identifies bridge as not predefined (per codebase pattern)', function () {
// 'bridge' is technically a Docker predefined network, but existing codebase
// only filters 'default' and 'host', so we maintain consistency
expect(isDockerPredefinedNetwork('bridge'))->toBeFalse();
});
it('identifies none as not predefined (per codebase pattern)', function () {
// 'none' is technically a Docker predefined network, but existing codebase
// only filters 'default' and 'host', so we maintain consistency
expect(isDockerPredefinedNetwork('none'))->toBeFalse();
});

View file

@ -0,0 +1,149 @@
<?php
use App\Models\Service;
use Illuminate\Support\Facades\Log;
beforeEach(function () {
Log::shouldReceive('error')->andReturn(null);
});
it('applies beszel gzip prerequisite correctly', function () {
// Create a simple object to track the property change
$application = new class
{
public $is_gzip_enabled = true;
public function save() {}
};
$query = Mockery::mock();
$query->shouldReceive('whereName')
->with('beszel')
->once()
->andReturnSelf();
$query->shouldReceive('first')
->once()
->andReturn($application);
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'beszel-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldReceive('applications')
->once()
->andReturn($query);
applyServiceApplicationPrerequisites($service);
expect($application->is_gzip_enabled)->toBeFalse();
});
it('applies appwrite stripprefix prerequisite correctly', function () {
$applications = [];
foreach (['appwrite', 'appwrite-console', 'appwrite-realtime'] as $name) {
$app = new class
{
public $is_stripprefix_enabled = true;
public function save() {}
};
$applications[$name] = $app;
}
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'appwrite-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldReceive('applications')->times(3)->andReturnUsing(function () use (&$applications) {
static $callCount = 0;
$names = ['appwrite', 'appwrite-console', 'appwrite-realtime'];
$currentName = $names[$callCount++];
$query = Mockery::mock();
$query->shouldReceive('whereName')
->with($currentName)
->once()
->andReturnSelf();
$query->shouldReceive('first')
->once()
->andReturn($applications[$currentName]);
return $query;
});
applyServiceApplicationPrerequisites($service);
foreach ($applications as $app) {
expect($app->is_stripprefix_enabled)->toBeFalse();
}
});
it('handles missing applications gracefully', function () {
$query = Mockery::mock();
$query->shouldReceive('whereName')
->with('beszel')
->once()
->andReturnSelf();
$query->shouldReceive('first')
->once()
->andReturn(null);
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'beszel-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldReceive('applications')
->once()
->andReturn($query);
// Should not throw exception
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});
it('skips services without prerequisites', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'unknown-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldNotReceive('applications');
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});
it('correctly parses service name with single hyphen', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'docker-registry-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldNotReceive('applications');
// Should not throw exception - validates that 'docker-registry' is correctly parsed
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});
it('correctly parses service name with multiple hyphens', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'elasticsearch-with-kibana-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldNotReceive('applications');
// Should not throw exception - validates that 'elasticsearch-with-kibana' is correctly parsed
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});
it('correctly parses service name with hyphens in template name', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'apprise-api-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldNotReceive('applications');
// Should not throw exception - validates that 'apprise-api' is correctly parsed
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});

View file

@ -7,7 +7,7 @@
// Simulate the command sequence from StopProxy // Simulate the command sequence from StopProxy
$commands = [ $commands = [
'docker stop --time=30 coolify-proxy 2>/dev/null || true', 'docker stop -t 30 coolify-proxy 2>/dev/null || true',
'docker rm -f coolify-proxy 2>/dev/null || true', 'docker rm -f coolify-proxy 2>/dev/null || true',
'# Wait for container to be fully removed', '# Wait for container to be fully removed',
'for i in {1..10}; do', 'for i in {1..10}; do',
@ -21,7 +21,7 @@
$commandsString = implode("\n", $commands); $commandsString = implode("\n", $commands);
// Verify the stop sequence includes all required components // Verify the stop sequence includes all required components
expect($commandsString)->toContain('docker stop --time=30 coolify-proxy') expect($commandsString)->toContain('docker stop -t 30 coolify-proxy')
->and($commandsString)->toContain('docker rm -f coolify-proxy') ->and($commandsString)->toContain('docker rm -f coolify-proxy')
->and($commandsString)->toContain('for i in {1..10}; do') ->and($commandsString)->toContain('for i in {1..10}; do')
->and($commandsString)->toContain('if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"') ->and($commandsString)->toContain('if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"')
@ -41,7 +41,7 @@
// Test that stop/remove commands suppress errors gracefully // Test that stop/remove commands suppress errors gracefully
$commands = [ $commands = [
'docker stop --time=30 coolify-proxy 2>/dev/null || true', 'docker stop -t 30 coolify-proxy 2>/dev/null || true',
'docker rm -f coolify-proxy 2>/dev/null || true', 'docker rm -f coolify-proxy 2>/dev/null || true',
]; ];
@ -54,9 +54,9 @@
// Verify that stop command includes the timeout parameter // Verify that stop command includes the timeout parameter
$timeout = 30; $timeout = 30;
$stopCommand = "docker stop --time=$timeout coolify-proxy 2>/dev/null || true"; $stopCommand = "docker stop -t $timeout coolify-proxy 2>/dev/null || true";
expect($stopCommand)->toContain('--time=30'); expect($stopCommand)->toContain('-t 30');
}); });
it('waits for swarm service container removal correctly', function () { it('waits for swarm service container removal correctly', function () {

View file

@ -0,0 +1,132 @@
<?php
use App\Actions\Server\UpdateCoolify;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
// Mock Server
$this->mockServer = Mockery::mock(Server::class)->makePartial();
$this->mockServer->id = 0;
// Mock InstanceSettings
$this->settings = Mockery::mock(InstanceSettings::class);
$this->settings->is_auto_update_enabled = true;
$this->settings->shouldReceive('save')->andReturn(true);
});
afterEach(function () {
Mockery::close();
});
it('has UpdateCoolify action class', function () {
expect(class_exists(UpdateCoolify::class))->toBeTrue();
});
it('validates cache against running version before fallback', function () {
// Mock Server::find to return our mock server
Server::shouldReceive('find')
->with(0)
->andReturn($this->mockServer);
// Mock instanceSettings
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
// CDN fails
Http::fake(['*' => Http::response(null, 500)]);
// Mock cache returning older version
Cache::shouldReceive('remember')
->andReturn(['coolify' => ['v4' => ['version' => '4.0.5']]]);
config(['constants.coolify.version' => '4.0.10']);
$action = new UpdateCoolify;
// Should throw exception - cache is older than running
try {
$action->handle(manual_update: false);
expect(false)->toBeTrue('Expected exception was not thrown');
} catch (\Exception $e) {
expect($e->getMessage())->toContain('cache version');
expect($e->getMessage())->toContain('4.0.5');
expect($e->getMessage())->toContain('4.0.10');
}
});
it('uses validated cache when CDN fails and cache is newer', function () {
// Mock Server::find
Server::shouldReceive('find')
->with(0)
->andReturn($this->mockServer);
// Mock instanceSettings
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
// CDN fails
Http::fake(['*' => Http::response(null, 500)]);
// Cache has newer version than current
Cache::shouldReceive('remember')
->andReturn(['coolify' => ['v4' => ['version' => '4.0.10']]]);
config(['constants.coolify.version' => '4.0.5']);
// Mock the update method to prevent actual update
$action = Mockery::mock(UpdateCoolify::class)->makePartial();
$action->shouldReceive('update')->once();
$action->server = $this->mockServer;
\Illuminate\Support\Facades\Log::shouldReceive('warning')
->once()
->with('Failed to fetch fresh version from CDN, using validated cache', Mockery::type('array'));
// Should not throw - cache (4.0.10) > running (4.0.5)
$action->handle(manual_update: false);
expect($action->latestVersion)->toBe('4.0.10');
});
it('prevents downgrade even with manual update', function () {
// Mock Server::find
Server::shouldReceive('find')
->with(0)
->andReturn($this->mockServer);
// Mock instanceSettings
$this->app->instance('App\Models\InstanceSettings', function () {
return $this->settings;
});
// CDN returns older version
Http::fake([
'*' => Http::response([
'coolify' => ['v4' => ['version' => '4.0.0']],
], 200),
]);
// Current version is newer
config(['constants.coolify.version' => '4.0.10']);
$action = new UpdateCoolify;
\Illuminate\Support\Facades\Log::shouldReceive('error')
->once()
->with('Downgrade prevented', Mockery::type('array'));
// Should throw exception even for manual updates
try {
$action->handle(manual_update: true);
expect(false)->toBeTrue('Expected exception was not thrown');
} catch (\Exception $e) {
expect($e->getMessage())->toContain('Cannot downgrade');
expect($e->getMessage())->toContain('4.0.10');
expect($e->getMessage())->toContain('4.0.0');
}
});

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.451" "version": "4.0.0-beta.452"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.452" "version": "4.0.0-beta.453"
}, },
"helper": { "helper": {
"version": "1.0.12" "version": "1.0.12"