v4.0.0-beta.452 (#7386)
This commit is contained in:
commit
a528f4c3d1
79 changed files with 1296 additions and 304 deletions
|
|
@ -39,7 +39,7 @@ public function handle(Application $application, bool $previewDeployments = fals
|
|||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ public function handle(Application $application, Server $server)
|
|||
if ($containerName) {
|
||||
instant_remote_process(
|
||||
[
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
],
|
||||
$server
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ public function handle(StandaloneClickhouse $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ public function handle(StandaloneDragonfly $database)
|
|||
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[] = "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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ public function handle(StandaloneKeydb $database)
|
|||
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[] = "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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ public function handle(StandaloneMariadb $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ public function handle(StandaloneMongodb $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ public function handle(StandaloneMysql $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ public function handle(StandalonePostgresql $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ public function handle(StandaloneRedis $database)
|
|||
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[] = "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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
|
|||
{
|
||||
$server = $database->destination->server;
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=$timeout $containerName",
|
||||
"docker stop -t $timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public function handle(Server $server, bool $forceStop = true, int $timeout = 30
|
|||
}
|
||||
|
||||
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",
|
||||
'# Wait for container to be fully removed',
|
||||
'for i in {1..10}; do',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
|
|
@ -29,7 +31,59 @@ public function handle($manual_update = false)
|
|||
return;
|
||||
}
|
||||
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');
|
||||
if (! $manual_update) {
|
||||
if (! $settings->is_auto_update_enabled) {
|
||||
|
|
@ -42,6 +96,20 @@ public function handle($manual_update = false)
|
|||
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();
|
||||
$settings->new_version_available = false;
|
||||
$settings->save();
|
||||
|
|
@ -56,8 +124,9 @@ private function update()
|
|||
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
|
||||
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
||||
|
||||
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||
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",
|
||||
], $this->server);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ private function stopContainersInParallel(array $containersToStop, Server $serve
|
|||
$timeout = count($containersToStop) > 5 ? 10 : 30;
|
||||
$commands = [];
|
||||
$containerList = implode(' ', $containersToStop);
|
||||
$commands[] = "docker stop --time=$timeout $containerList";
|
||||
$commands[] = "docker stop -t $timeout $containerList";
|
||||
$commands[] = "docker rm -f $containerList";
|
||||
instant_remote_process(
|
||||
command: $commands,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ private function showHelp()
|
|||
php artisan app:demo-notify {channel}
|
||||
</p>
|
||||
<div class="my-1">
|
||||
<div class="text-yellow-500"> Channels: </div>
|
||||
<div class="text-warning-500"> Channels: </div>
|
||||
<ul class="text-coolify">
|
||||
<li>email</li>
|
||||
<li>discord</li>
|
||||
|
|
|
|||
|
|
@ -1652,6 +1652,10 @@ private function create_application(Request $request, $type)
|
|||
$service->save();
|
||||
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@ public function create_service(Request $request)
|
|||
'destination_id' => $destination->id,
|
||||
'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);
|
||||
}
|
||||
$service = Service::create($servicePayload);
|
||||
|
|
@ -376,6 +376,10 @@ public function create_service(Request $request)
|
|||
});
|
||||
}
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1401,15 +1401,44 @@ private function generate_buildtime_environment_variables()
|
|||
$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);
|
||||
foreach ($coolify_envs as $key => $item) {
|
||||
$envs_dict[$key] = escapeBashEnvValue($item);
|
||||
}
|
||||
|
||||
// Add COOLIFY variables
|
||||
$coolify_envs->each(function ($item, $key) use ($envs) {
|
||||
$envs->push($key.'='.escapeBashEnvValue($item));
|
||||
});
|
||||
|
||||
// Add SERVICE_NAME variables for Docker Compose builds
|
||||
// 3. Add SERVICE_NAME, SERVICE_FQDN, SERVICE_URL variables for Docker Compose builds
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
if ($this->pull_request_id === 0) {
|
||||
// Generate SERVICE_NAME for dockercompose services from processed compose
|
||||
|
|
@ -1420,7 +1449,7 @@ private function generate_buildtime_environment_variables()
|
|||
}
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
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
|
||||
|
|
@ -1433,8 +1462,8 @@ private function generate_buildtime_environment_variables()
|
|||
$coolifyScheme = $coolifyUrl->getScheme();
|
||||
$coolifyFqdn = $coolifyUrl->getHost();
|
||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
|
||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
|
||||
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
|
||||
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1442,7 +1471,7 @@ private function generate_buildtime_environment_variables()
|
|||
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||
$rawServices = data_get($rawDockerCompose, 'services', []);
|
||||
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
|
||||
|
|
@ -1455,17 +1484,16 @@ private function generate_buildtime_environment_variables()
|
|||
$coolifyScheme = $coolifyUrl->getScheme();
|
||||
$coolifyFqdn = $coolifyUrl->getHost();
|
||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
|
||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
|
||||
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
|
||||
$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) {
|
||||
$sorted_environment_variables = $this->application->environment_variables()
|
||||
->where('key', 'not like', 'NIXPACKS_%')
|
||||
->where('is_buildtime', true) // ONLY build-time variables
|
||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||
->get();
|
||||
|
|
@ -1483,7 +1511,12 @@ private function generate_buildtime_environment_variables()
|
|||
// Strip outer quotes from real_value and apply proper bash escaping
|
||||
$value = trim($env->real_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()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
|
|
@ -1495,7 +1528,12 @@ private function generate_buildtime_environment_variables()
|
|||
} else {
|
||||
// For normal vars, use double quotes to allow $VAR expansion
|
||||
$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()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
|
|
@ -1507,7 +1545,6 @@ private function generate_buildtime_environment_variables()
|
|||
}
|
||||
} else {
|
||||
$sorted_environment_variables = $this->application->environment_variables_preview()
|
||||
->where('key', 'not like', 'NIXPACKS_%')
|
||||
->where('is_buildtime', true) // ONLY build-time variables
|
||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||
->get();
|
||||
|
|
@ -1525,7 +1562,12 @@ private function generate_buildtime_environment_variables()
|
|||
// Strip outer quotes from real_value and apply proper bash escaping
|
||||
$value = trim($env->real_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()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
|
|
@ -1537,7 +1579,12 @@ private function generate_buildtime_environment_variables()
|
|||
} else {
|
||||
// For normal vars, use double quotes to allow $VAR expansion
|
||||
$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()) {
|
||||
$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
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||
|
|
@ -3090,7 +3143,7 @@ private function graceful_shutdown_container(string $containerName)
|
|||
try {
|
||||
$timeout = isDev() ? 1 : 30;
|
||||
$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]
|
||||
);
|
||||
} catch (Exception $error) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
|
|
@ -22,20 +23,60 @@ public function handle(): void
|
|||
return;
|
||||
}
|
||||
$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()) {
|
||||
$versions = $response->json();
|
||||
|
||||
$latest_version = data_get($versions, 'coolify.v4.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, '>')) {
|
||||
// New version available
|
||||
$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 {
|
||||
$settings->update(['new_version_available' => false]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ public function __construct() {}
|
|||
public function handle(): void
|
||||
{
|
||||
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()) {
|
||||
$versions = $response->json();
|
||||
$settings = instanceSettings();
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
|
|||
|
||||
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
|
||||
$commands = [
|
||||
"docker stop --time=$timeout $containerList",
|
||||
"docker stop -t $timeout $containerList",
|
||||
"docker rm -f $containerList",
|
||||
];
|
||||
instant_remote_process(
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ private function stopContainers(array $containers, $server)
|
|||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ public function submit()
|
|||
}
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
return redirect()->route('project.service.configuration', [
|
||||
'service_uuid' => $service->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ public function mount()
|
|||
'destination_id' => $destination->id,
|
||||
'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);
|
||||
}
|
||||
$service = Service::create($service_payload);
|
||||
|
|
@ -104,6 +104,9 @@ public function mount()
|
|||
}
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
return redirect()->route('project.service.configuration', [
|
||||
'service_uuid' => $service->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class GetLogs extends Component
|
|||
|
||||
public ?bool $streamLogs = false;
|
||||
|
||||
public ?bool $showTimeStamps = true;
|
||||
public ?bool $showTimeStamps = false;
|
||||
|
||||
public ?int $numberOfLines = 100;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Component;
|
||||
|
||||
class Navbar extends Component
|
||||
|
|
@ -72,7 +73,15 @@ public function restart()
|
|||
|
||||
// Check Traefik version after restart to provide immediate feedback
|
||||
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) {
|
||||
return handleError($e, $this);
|
||||
|
|
|
|||
|
|
@ -79,20 +79,19 @@ protected function getTraefikVersions(): ?array
|
|||
|
||||
// Load from global cached helper (Redis + filesystem)
|
||||
$versionsData = get_versions_data();
|
||||
$this->cachedVersionsFile = $versionsData;
|
||||
|
||||
if (! $versionsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->cachedVersionsFile = $versionsData;
|
||||
$traefikVersions = data_get($versionsData, 'traefik');
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@
|
|||
|
||||
class Upgrade extends Component
|
||||
{
|
||||
public bool $showProgress = false;
|
||||
|
||||
public bool $updateInProgress = false;
|
||||
|
||||
public bool $isUpgradeAvailable = false;
|
||||
|
|
|
|||
|
|
@ -67,4 +67,14 @@
|
|||
'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'];
|
||||
|
|
|
|||
|
|
@ -6,6 +6,20 @@
|
|||
use App\Models\Server;
|
||||
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)
|
||||
{
|
||||
if (! $server->isFunctional()) {
|
||||
|
|
@ -66,8 +80,12 @@ function collectDockerNetworksByServer(Server $server)
|
|||
$networks->push($network);
|
||||
$allNetworks->push($network);
|
||||
}
|
||||
$networks = collect($networks)->flatten()->unique();
|
||||
$allNetworks = $allNetworks->flatten()->unique();
|
||||
$networks = collect($networks)->flatten()->unique()->filter(function ($network) {
|
||||
return ! isDockerPredefinedNetwork($network);
|
||||
});
|
||||
$allNetworks = $allNetworks->flatten()->unique()->filter(function ($network) {
|
||||
return ! isDockerPredefinedNetwork($network);
|
||||
});
|
||||
if ($server->isSwarm()) {
|
||||
if ($networks->count() === 0) {
|
||||
$networks = collect(['coolify-overlay']);
|
||||
|
|
@ -219,8 +237,8 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
|
|||
$array_of_networks = collect([]);
|
||||
$filtered_networks = collect([]);
|
||||
$networks->map(function ($network) use ($array_of_networks, $filtered_networks) {
|
||||
if ($network === 'host') {
|
||||
return; // network-scoped alias is supported only for containers in user defined networks
|
||||
if (isDockerPredefinedNetwork($network)) {
|
||||
return; // Predefined networks cannot be used in network configuration
|
||||
}
|
||||
|
||||
$array_of_networks[$network] = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Stringable;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
|
@ -339,3 +340,54 @@ function parseServiceEnvironmentVariable(string $key): array
|
|||
'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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ function get_route_parameters(): array
|
|||
function get_latest_sentinel_version(): string
|
||||
{
|
||||
try {
|
||||
$response = Http::get('https://cdn.coollabs.io/coolify/versions.json');
|
||||
$response = Http::get(config('constants.coolify.versions_url'));
|
||||
$versions = $response->json();
|
||||
|
||||
return data_get($versions, 'coolify.sentinel.version');
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.451',
|
||||
'version' => '4.0.0-beta.452',
|
||||
'helper_version' => '1.0.12',
|
||||
'realtime_version' => '1.0.10',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
|
@ -12,6 +12,9 @@
|
|||
'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'),
|
||||
'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',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ public function up(): void
|
|||
$table->boolean('server_reachable_webhook_notifications')->default(false);
|
||||
$table->boolean('server_unreachable_webhook_notifications')->default(true);
|
||||
$table->boolean('server_patch_webhook_notifications')->default(false);
|
||||
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
|
||||
|
||||
$table->unique(['team_id']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,9 +19,13 @@ public function up(): void
|
|||
$table->boolean('traefik_outdated_slack_notifications')->default(true);
|
||||
});
|
||||
|
||||
Schema::table('webhook_notification_settings', function (Blueprint $table) {
|
||||
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
|
||||
});
|
||||
// Only add if table exists and column doesn't exist
|
||||
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) {
|
||||
$table->boolean('traefik_outdated_telegram_notifications')->default(true);
|
||||
|
|
@ -45,9 +49,13 @@ public function down(): void
|
|||
$table->dropColumn('traefik_outdated_slack_notifications');
|
||||
});
|
||||
|
||||
Schema::table('webhook_notification_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('traefik_outdated_webhook_notifications');
|
||||
});
|
||||
// Only drop if table and column exist
|
||||
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) {
|
||||
$table->dropColumn('traefik_outdated_telegram_notifications');
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.451"
|
||||
"version": "4.0.0-beta.452"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.452"
|
||||
"version": "4.0.0-beta.453"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.12"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ @theme {
|
|||
|
||||
--color-base: #101010;
|
||||
--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-error: #dc2626;
|
||||
--color-coollabs-50: #f5f0ff;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ @utility input-sticky {
|
|||
}
|
||||
|
||||
@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 */
|
||||
|
|
@ -154,7 +154,7 @@ @utility badge {
|
|||
}
|
||||
|
||||
@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 {
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
||||
@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 {
|
||||
@apply rounded-sm hover:bg-neutral-300 dark:hover:bg-coolgray-500/20;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
@php
|
||||
$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>',
|
||||
|
||||
|
|
@ -13,10 +13,10 @@
|
|||
|
||||
$colors = [
|
||||
'warning' => [
|
||||
'bg' => 'bg-yellow-50 dark:bg-yellow-900/30',
|
||||
'border' => 'border-yellow-300 dark:border-yellow-800',
|
||||
'title' => 'text-yellow-800 dark:text-yellow-300',
|
||||
'text' => 'text-yellow-700 dark:text-yellow-200'
|
||||
'bg' => 'bg-warning-50 dark:bg-warning-900/30',
|
||||
'border' => 'border-warning-300 dark:border-warning-800',
|
||||
'title' => 'text-warning-800 dark:text-warning-300',
|
||||
'text' => 'text-warning-700 dark:text-warning-200'
|
||||
],
|
||||
'danger' => [
|
||||
'bg' => 'bg-red-50 dark:bg-red-900/30',
|
||||
|
|
|
|||
|
|
@ -1,27 +1,22 @@
|
|||
<div @class([
|
||||
'transition-all duration-150 box-without-bg dark:bg-coolgray-100 bg-white group',
|
||||
'hover:border-l-coollabs cursor-pointer' => !$upgrade,
|
||||
'hover:border-l-red-500 cursor-not-allowed' => $upgrade,
|
||||
])>
|
||||
<div @class([
|
||||
'coolbox group',
|
||||
'!cursor-not-allowed hover:border-l-red-500' => $upgrade,
|
||||
])>
|
||||
<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 }}
|
||||
</div>
|
||||
<div class="flex flex-col pl-2 ">
|
||||
<div class="dark:text-white text-md">
|
||||
<div class="flex flex-col pl-3 space-y-1">
|
||||
<div class="dark:text-white text-md font-medium">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($upgrade)
|
||||
<div>{{ $upgrade }}</div>
|
||||
@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 }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@isset($documentation)
|
||||
<div class="flex-1"></div>
|
||||
{{ $documentation }}
|
||||
@endisset
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
@if ($foundUsers->count() > 0)
|
||||
<div class="flex flex-wrap gap-2 pt-4">
|
||||
@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="box-title">{{ $user->name }}</div>
|
||||
<div class="box-description">{{ $user->email }}</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class="flex items-center justify-center size-4 text-white rounded hover:bg-coolg
|
|||
@if ($projects->count() > 0)
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
@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>
|
||||
<div class="flex flex-1 mx-6">
|
||||
<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)
|
||||
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}"
|
||||
@class([
|
||||
'gap-2 border cursor-pointer box group',
|
||||
'gap-2 border cursor-pointer coolbox group',
|
||||
'border-red-500' =>
|
||||
!$server->settings->is_reachable || $server->settings->force_disabled,
|
||||
])>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
@forelse ($servers as $server)
|
||||
@forelse ($server->destinations() as $destination)
|
||||
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
||||
<a class="box group"
|
||||
<a class="coolbox group"
|
||||
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">{{ $destination->name }}</div>
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
</a>
|
||||
@endif
|
||||
@if ($destination->getMorphClass() === 'App\Models\SwarmDocker')
|
||||
<a class="box group"
|
||||
<a class="coolbox group"
|
||||
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
|
||||
<div class="flex flex-col mx-6">
|
||||
<div class="box-title">{{ $destination->name }}</div>
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
|
|||
</div>
|
||||
@if ($loadingServers)
|
||||
<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">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
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)
|
||||
@foreach ($availableServers as $index => $server)
|
||||
<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-1 min-w-0">
|
||||
<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
|
||||
</div>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
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>
|
||||
@if ($loadingDestinations)
|
||||
<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">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
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)
|
||||
@foreach ($availableDestinations as $index => $destination)
|
||||
<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-1 min-w-0">
|
||||
<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>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
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>
|
||||
@if ($loadingProjects)
|
||||
<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">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
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)
|
||||
@foreach ($availableProjects as $index => $project)
|
||||
<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-1 min-w-0">
|
||||
<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
|
||||
</div>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
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>
|
||||
@if ($loadingEnvironments)
|
||||
<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">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
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)
|
||||
@foreach ($availableEnvironments as $index => $environment)
|
||||
<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-1 min-w-0">
|
||||
<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
|
||||
</div>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
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)
|
||||
@if (!isset($result['is_creatable_suggestion']))
|
||||
<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-1 min-w-0">
|
||||
<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 -->
|
||||
@foreach ($items as $item)
|
||||
<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 gap-3 flex-1 min-w-0">
|
||||
<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"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4v16m8-8H4" />
|
||||
|
|
@ -708,7 +708,7 @@ class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickc
|
|||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
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 x-for="(result, index) in searchResults" :key="index">
|
||||
<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-1 min-w-0">
|
||||
<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">
|
||||
<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 gap-3 flex-1 min-w-0">
|
||||
<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"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
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>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
|
|
|
|||
|
|
@ -386,7 +386,7 @@
|
|||
@if ($this->detectedPortInfo)
|
||||
@if ($this->detectedPortInfo['isEmpty'])
|
||||
<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">
|
||||
<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"
|
||||
|
|
@ -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>
|
||||
@elseif (!$this->detectedPortInfo['matches'])
|
||||
<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">
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
<x-slide-over @startdatabase.window="slideOverOpen = true" closeWithX fullScreen>
|
||||
<x-slot:title>Database Startup</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:activity-monitor header="Logs" fullHeight />
|
||||
<div wire:ignore>
|
||||
<livewire:activity-monitor header="Logs" fullHeight />
|
||||
</div>
|
||||
</x-slot:content>
|
||||
</x-slide-over>
|
||||
<div class="navbar-main">
|
||||
|
|
|
|||
|
|
@ -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-slot:title>Database Restore Output</x-slot:title>
|
||||
<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-slide-over>
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
@if ($backup->latest_log)
|
||||
@if (data_get($backup->latest_log, 'status') === 'running')
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
|
||||
Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
|
||||
<span
|
||||
title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
|
||||
Running for
|
||||
{{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
|
||||
</span>
|
||||
@else
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
|
||||
<span
|
||||
title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
|
||||
{{ \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')) }})
|
||||
• {{ \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>
|
||||
@endif
|
||||
@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">
|
||||
@if ($backup->latest_log)
|
||||
@if (data_get($backup->latest_log, 'status') === 'running')
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
|
||||
Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
|
||||
<span
|
||||
title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
|
||||
Running for
|
||||
{{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
|
||||
</span>
|
||||
@else
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
|
||||
<span
|
||||
title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
|
||||
{{ \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')) }})
|
||||
• {{ \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>
|
||||
@endif
|
||||
@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([
|
||||
'font-medium',
|
||||
'text-green-600' => $successRate >= 80,
|
||||
'text-yellow-600' => $successRate >= 50 && $successRate < 80,
|
||||
'text-warning-600' => $successRate >= 50 && $successRate < 80,
|
||||
'text-red-600' => $successRate < 50,
|
||||
])>{{ $successRate }}%</span>
|
||||
({{ $successCount }}/{{ $totalCount }})
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<div class="subtitle">All your projects are here.</div>
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 -mt-1">
|
||||
@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>
|
||||
<div class="flex flex-1 mx-6">
|
||||
<div class="flex flex-col justify-center flex-1">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<div class="flex flex-col justify-center gap-2 text-left ">
|
||||
@forelse ($private_keys as $key)
|
||||
@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 }}">
|
||||
<div class="flex flex-col mx-6">
|
||||
<div class="box-title">
|
||||
|
|
@ -20,7 +20,7 @@ class="loading loading-xs dark:text-warning loading-spinner"></span>
|
|||
</div>
|
||||
</div>
|
||||
@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 }}">
|
||||
<div class="flex flex-col mx-6">
|
||||
<div class="box-title">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<div class="flex flex-col justify-center gap-2 text-left">
|
||||
@foreach ($github_apps as $ghapp)
|
||||
<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:key="{{ $ghapp->id }}">
|
||||
<div class="flex mr-4">
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
<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()">
|
||||
@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">
|
||||
<input autocomplete="off" x-ref="searchInput" class="input-sticky flex-1"
|
||||
:class="{ 'input-sticky-active': isSticky }" x-model="search" placeholder="Type / to search..."
|
||||
|
|
@ -23,25 +24,34 @@
|
|||
<div x-show="loading || categories.length === 0"
|
||||
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-neutral-100 dark:bg-coolgray-200 cursor-not-allowed whitespace-nowrap opacity-50">
|
||||
<span class="text-sm text-neutral-400 dark:text-neutral-600">Filter by category</span>
|
||||
<svg class="w-4 h-4 text-neutral-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<svg class="w-4 h-4 text-neutral-400 shrink-0" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Active State -->
|
||||
<div x-show="!loading && categories.length > 0"
|
||||
@click="openCategoryDropdown = !openCategoryDropdown; $nextTick(() => { if (openCategoryDropdown) $refs.categorySearchInput.focus() })"
|
||||
class="flex items-center justify-between gap-2 py-1.5 px-3 w-64 text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-pointer hover:ring-coolgray-400 transition-all whitespace-nowrap">
|
||||
<span class="text-sm truncate" x-text="selectedCategory === '' ? 'Filter by category' : selectedCategory" :class="selectedCategory === '' ? 'text-neutral-400 dark:text-neutral-600' : 'capitalize text-black dark:text-white'"></span>
|
||||
<svg class="w-4 h-4 transition-transform text-neutral-400 shrink-0" :class="{ 'rotate-180': openCategoryDropdown }" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<span class="text-sm truncate"
|
||||
x-text="selectedCategory === '' ? 'Filter by category' : selectedCategory"
|
||||
:class="selectedCategory === '' ? 'text-neutral-400 dark:text-neutral-600' :
|
||||
'capitalize text-black dark:text-white'"></span>
|
||||
<svg class="w-4 h-4 transition-transform text-neutral-400 shrink-0"
|
||||
:class="{ 'rotate-180': openCategoryDropdown }" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Dropdown Menu -->
|
||||
<div x-show="openCategoryDropdown" x-transition
|
||||
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg overflow-hidden">
|
||||
<div class="sticky top-0 p-2 bg-white dark:bg-coolgray-100 border-b border-neutral-300 dark:border-coolgray-400">
|
||||
<input type="text" x-ref="categorySearchInput" x-model="categorySearch" placeholder="Search categories..."
|
||||
<div
|
||||
class="sticky top-0 p-2 bg-white dark:bg-coolgray-100 border-b border-neutral-300 dark:border-coolgray-400">
|
||||
<input type="text" x-ref="categorySearchInput" x-model="categorySearch"
|
||||
placeholder="Search categories..."
|
||||
class="w-full px-2 py-1 text-sm rounded border border-neutral-300 dark:border-coolgray-400 bg-white dark:bg-coolgray-200 focus:outline-none focus:ring-2 focus:ring-coolgray-400"
|
||||
@click.stop>
|
||||
</div>
|
||||
|
|
@ -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 === '' }">
|
||||
<span class="text-sm">All Categories</span>
|
||||
</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"
|
||||
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 }">
|
||||
|
|
@ -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" class="flex flex-col gap-4 py-4">
|
||||
<h2 x-show="filteredGitBasedApplications.length > 0">Applications</h2>
|
||||
<h4 x-show="filteredGitBasedApplications.length > 0">Git Based</h4>
|
||||
<div x-show="filteredGitBasedApplications.length > 0"
|
||||
class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-1">
|
||||
<template x-for="application in filteredGitBasedApplications" :key="application.name">
|
||||
<div x-on:click='setType(application.id)'
|
||||
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
|
||||
<x-resource-view>
|
||||
<x-slot:title><span x-text="application.name"></span></x-slot>
|
||||
<x-slot:description>
|
||||
<span x-html="window.sanitizeHTML(application.description)"></span>
|
||||
<div x-show="filteredGitBasedApplications.length > 0 || filteredDockerBasedApplications.length > 0"
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div x-show="filteredGitBasedApplications.length > 0" class="space-y-4">
|
||||
<h4>Git Based</h4>
|
||||
<div class="grid justify-start grid-cols-1 gap-4 text-left">
|
||||
<template x-for="application in filteredGitBasedApplications" :key="application.name">
|
||||
<div x-on:click='setType(application.id)'
|
||||
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
|
||||
<x-resource-view>
|
||||
<x-slot:title><span x-text="application.name"></span></x-slot>
|
||||
<x-slot:description>
|
||||
<span x-html="window.sanitizeHTML(application.description)"></span>
|
||||
</x-slot>
|
||||
<x-slot:logo>
|
||||
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
|
||||
:src="application.logo">
|
||||
</x-slot:logo>
|
||||
</x-resource-view>
|
||||
</x-resource-view>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<h4 x-show="filteredDockerBasedApplications.length > 0">Docker Based</h4>
|
||||
<div x-show="filteredDockerBasedApplications.length > 0"
|
||||
class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3">
|
||||
<template x-for="application in filteredDockerBasedApplications" :key="application.name">
|
||||
<div x-on:click="setType(application.id)"
|
||||
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
|
||||
<x-resource-view>
|
||||
<x-slot:title><span x-text="application.name"></span></x-slot>
|
||||
<x-slot:description><span x-text="application.description"></span></x-slot>
|
||||
</div>
|
||||
<div x-show="filteredDockerBasedApplications.length > 0" class="space-y-4">
|
||||
<h4>Docker Based</h4>
|
||||
<div class="grid justify-start grid-cols-1 gap-4 text-left">
|
||||
<template x-for="application in filteredDockerBasedApplications" :key="application.name">
|
||||
<div x-on:click="setType(application.id)"
|
||||
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
|
||||
<x-resource-view>
|
||||
<x-slot:title><span x-text="application.name"></span></x-slot>
|
||||
<x-slot:description><span x-text="application.description"></span></x-slot>
|
||||
<x-slot:logo> <img
|
||||
class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
|
||||
:src="application.logo"></x-slot>
|
||||
</x-resource-view>
|
||||
</x-resource-view>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<h2 x-show="filteredDatabases.length > 0">Databases</h2>
|
||||
<div x-show="filteredDatabases.length > 0"
|
||||
class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-2">
|
||||
<template x-for="database in filteredDatabases" :key="database.id">
|
||||
<div x-on:click="setType(database.id)"
|
||||
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
|
||||
<x-resource-view>
|
||||
<x-slot:title><span x-text="database.name"></span></x-slot>
|
||||
<div x-show="filteredDatabases.length > 0" class="mt-8">
|
||||
<h2 class="mb-4">Databases</h2>
|
||||
<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">
|
||||
<div x-on:click="setType(database.id)"
|
||||
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
|
||||
<x-resource-view>
|
||||
<x-slot:title><span x-text="database.name"></span></x-slot>
|
||||
<x-slot:description><span x-text="database.description"></span></x-slot>
|
||||
<x-slot:logo>
|
||||
<span x-show="database.logo">
|
||||
<span x-html="database.logo"></span>
|
||||
</span>
|
||||
</x-slot>
|
||||
</x-resource-view>
|
||||
</div>
|
||||
</template>
|
||||
<x-slot:logo>
|
||||
<span x-show="database.logo">
|
||||
<span x-html="database.logo"></span>
|
||||
</span>
|
||||
</x-slot>
|
||||
</x-resource-view>
|
||||
</div>
|
||||
</template>
|
||||
</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">
|
||||
<h2>Services</h2>
|
||||
<x-forms.button x-on:click="loadResources">Reload List</x-forms.button>
|
||||
</div>
|
||||
<div class="py-4 text-xs">Trademarks Policy: The respective trademarks mentioned here are owned by
|
||||
the
|
||||
respective
|
||||
companies, and use of them does not imply any affiliation or endorsement.<br>Find more services
|
||||
<a class="dark:text-white underline" target="_blank"
|
||||
href="https://coolify.io/docs/services/overview">here</a>.
|
||||
</div>
|
||||
<x-callout type="info" title="Trademarks Policy" class="mt-4 mb-6">
|
||||
The respective trademarks mentioned here are owned by the respective companies, and use of them
|
||||
does not imply any affiliation or endorsement.
|
||||
</x-callout>
|
||||
|
||||
<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">
|
||||
<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 }">
|
||||
<x-resource-view>
|
||||
<x-slot:title>
|
||||
<template x-if="service.name">
|
||||
<span x-text="service.name"></span>
|
||||
</template>
|
||||
</x-slot>
|
||||
<x-slot:description>
|
||||
<template x-if="service.slogan">
|
||||
<span x-text="service.slogan"></span>
|
||||
</template>
|
||||
</x-slot>
|
||||
<x-slot:logo>
|
||||
<template x-if="service.logo">
|
||||
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
|
||||
:src='service.logo'
|
||||
x-on:error.window="$event.target.src = service.logo_github_url"
|
||||
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
|
||||
x-on:error="$event.target.src = '/coolify-logo.svg'"
|
||||
:data-fallback='service.logo_github_url' />
|
||||
</template>
|
||||
</x-slot:logo>
|
||||
<x-slot:documentation>
|
||||
<template x-if="service.documentation">
|
||||
<div class="flex items-center px-2" title="Read the documentation.">
|
||||
<a class="p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-coolgray-200 hover:no-underline dark:group-hover:text-white text-neutral-600"
|
||||
onclick="event.stopPropagation()" :href="service.documentation"
|
||||
target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</x-slot:documentation>
|
||||
</x-slot>
|
||||
<x-slot:description>
|
||||
<template x-if="service.slogan">
|
||||
<span x-text="service.slogan"></span>
|
||||
</template>
|
||||
</x-slot>
|
||||
<x-slot:logo>
|
||||
<template x-if="service.logo">
|
||||
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
|
||||
:src='service.logo'
|
||||
x-on:error.window="$event.target.src = service.logo_github_url"
|
||||
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
|
||||
x-on:error="$event.target.src = '/coolify-logo.svg'"
|
||||
:data-fallback='service.logo_github_url' />
|
||||
</template>
|
||||
</x-slot:logo>
|
||||
</x-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>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -195,6 +212,8 @@ function searchResources() {
|
|||
gitBasedApplications: [],
|
||||
dockerBasedApplications: [],
|
||||
databases: [],
|
||||
docLinkCache: {}, // Cache resolved doc URLs: { serviceName: url | null }
|
||||
docCheckInProgress: {}, // Track ongoing checks: { serviceName: boolean }
|
||||
setType(type) {
|
||||
if (this.selecting) return;
|
||||
this.selecting = true;
|
||||
|
|
@ -219,6 +238,81 @@ function searchResources() {
|
|||
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) {
|
||||
const searchLower = this.search.trim().toLowerCase();
|
||||
let filtered = Object.values(items);
|
||||
|
|
@ -229,9 +323,10 @@ function searchResources() {
|
|||
filtered = filtered.filter(item => {
|
||||
if (!item.category) return false;
|
||||
// Handle comma-separated categories
|
||||
const categories = item.category.includes(',')
|
||||
? item.category.split(',').map(c => c.trim().toLowerCase())
|
||||
: [item.category.toLowerCase()];
|
||||
const categories = item.category.includes(',') ?
|
||||
item.category.split(',').map(c => c.trim().toLowerCase()) : [item.category
|
||||
.toLowerCase()
|
||||
];
|
||||
return categories.includes(selectedCategoryLower);
|
||||
});
|
||||
}
|
||||
|
|
@ -295,7 +390,7 @@ function searchResources() {
|
|||
</a> </div>
|
||||
@else
|
||||
@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="box-title">
|
||||
{{ $server->name }}
|
||||
|
|
@ -308,7 +403,8 @@ function searchResources() {
|
|||
@empty
|
||||
<div>
|
||||
|
||||
<div>No validated & reachable servers found. <a class="underline dark:text-white" href="/servers">
|
||||
<div>No validated & reachable servers found. <a class="underline dark:text-white"
|
||||
href="/servers">
|
||||
Go to servers page
|
||||
</a></div>
|
||||
</div>
|
||||
|
|
@ -324,7 +420,7 @@ function searchResources() {
|
|||
<div class="flex flex-col justify-center gap-4 text-left xl:flex-row xl:flex-wrap">
|
||||
@if ($server->isSwarm())
|
||||
@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="font-bold dark:group-hover:text-white">
|
||||
Swarm Docker <span class="text-xs">({{ $swarmDocker->name }})</span>
|
||||
|
|
@ -334,7 +430,7 @@ function searchResources() {
|
|||
@endforeach
|
||||
@else
|
||||
@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="box-title">
|
||||
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.">
|
||||
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-6000"
|
||||
onclick="event.stopPropagation()" href="https://hub.docker.com/_/postgres/" target="_blank">
|
||||
onclick="event.stopPropagation()" href="https://hub.docker.com/_/postgres/"
|
||||
target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -386,7 +483,8 @@ function searchResources() {
|
|||
<div class="flex-1"></div>
|
||||
<div class="flex items-center px-2" title="Read the documentation.">
|
||||
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-600"
|
||||
onclick="event.stopPropagation()" href="https://github.com/supabase/postgres" target="_blank">
|
||||
onclick="event.stopPropagation()" href="https://github.com/supabase/postgres"
|
||||
target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -424,7 +522,8 @@ function searchResources() {
|
|||
|
||||
<div class="flex items-center px-2" title="Read the documentation.">
|
||||
<a class="p-2 hover:underline dark:group-hover:text-white dark:text-white text-neutral-600"
|
||||
onclick="event.stopPropagation()" href="https://github.com/pgvector/pgvector" target="_blank">
|
||||
onclick="event.stopPropagation()" href="https://github.com/pgvector/pgvector"
|
||||
target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -439,4 +538,4 @@ function searchResources() {
|
|||
<x-forms.button type="submit">Add Database</x-forms.button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class="button">+
|
|||
@if ($environment->isEmpty())
|
||||
@can('createAnyResource')
|
||||
<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
|
||||
<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">
|
||||
|
|
@ -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">
|
||||
<template x-for="item in filteredApplications" :key="item.uuid">
|
||||
<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 gap-2 px-4">
|
||||
<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">
|
||||
<template x-for="item in filteredDatabases" :key="item.uuid">
|
||||
<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 gap-2 px-4">
|
||||
<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">
|
||||
<template x-for="item in filteredServices" :key="item.uuid">
|
||||
<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 gap-2 px-4">
|
||||
<div class="pb-2 truncate box-title" x-text="item.name"></div>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class="absolute bg-error -top-1 -left-1 badge "></div>
|
|||
<div class="grid grid-cols-1 gap-4">
|
||||
@foreach ($networks as $network)
|
||||
<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 class="box-title">
|
||||
Server: {{ data_get($network, 'server.name') }}
|
||||
|
|
|
|||
|
|
@ -102,9 +102,64 @@
|
|||
</div>
|
||||
</div>
|
||||
@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
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<div class="flex flex-col flex-wrap gap-2 pt-4">
|
||||
@forelse($resource->scheduled_tasks as $task)
|
||||
@if ($resource->type() == 'application')
|
||||
<a class="box"
|
||||
<a class="coolbox"
|
||||
href="{{ route('project.application.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}">
|
||||
<span class="flex flex-col">
|
||||
<span class="text-lg font-bold">{{ $task->name }}
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
</span>
|
||||
</a>
|
||||
@elseif ($resource->type() == 'service')
|
||||
<a class="box"
|
||||
<a class="coolbox"
|
||||
href="{{ route('project.service.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}">
|
||||
<span class="flex flex-col">
|
||||
<span class="text-lg font-bold">{{ $task->name }}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<div class="text-xs truncate subtitle lg:text-sm">{{ $project->name }}.</div>
|
||||
<div class="grid gap-2 lg:grid-cols-2">
|
||||
@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">
|
||||
<a class="flex flex-col justify-center flex-1"
|
||||
href="{{ route('project.resource.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
@forelse ($privateKeys as $key)
|
||||
@can('view', $key)
|
||||
{{-- 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')]) }}">
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">
|
||||
|
|
@ -26,14 +26,14 @@
|
|||
{{ $key->description }}
|
||||
@if (!$key->isInUse())
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@else
|
||||
{{-- 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="box-title">
|
||||
{{ 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 }}
|
||||
@if (!$key->isInUse())
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class="underline">documentation</a>.
|
|||
<div class="flex flex-col pb-2">
|
||||
<h3>Automated </h3>
|
||||
<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 class="flex gap-2">
|
||||
<x-slide-over @automated.window="slideOverOpen = true" fullScreen>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div>
|
||||
<x-modal-input title="Connect a Hetzner Server">
|
||||
<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">
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
@forelse ($servers as $server)
|
||||
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}"
|
||||
@class([
|
||||
'gap-2 border cursor-pointer box group',
|
||||
'gap-2 border cursor-pointer coolbox group',
|
||||
'border-red-500' =>
|
||||
!$server->settings->is_reachable || $server->settings->force_disabled,
|
||||
])>
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@
|
|||
<x-highlighted text="*" />
|
||||
</label>
|
||||
<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">
|
||||
No private keys found. You need to create a private key to continue.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -77,9 +77,9 @@
|
|||
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
|
||||
<button type="button" x-show="traefikWarningsDismissed"
|
||||
@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">
|
||||
<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>
|
||||
</svg>
|
||||
</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>
|
||||
@can('update', $server)
|
||||
<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)
|
||||
</x-forms.button>
|
||||
<x-forms.button class="box" wire:click="selectProxy('TRAEFIK')">
|
||||
<x-forms.button class="coolbox" wire:click="selectProxy('TRAEFIK')">
|
||||
Traefik
|
||||
</x-forms.button>
|
||||
<x-forms.button class="box" wire:click="selectProxy('CADDY')">
|
||||
<x-forms.button class="coolbox" wire:click="selectProxy('CADDY')">
|
||||
Caddy
|
||||
</x-forms.button>
|
||||
{{-- <x-forms.button disabled class="box">
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
@if ($hetznerServerStatus)
|
||||
<span class="pl-1.5">
|
||||
@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">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
||||
stroke="currentColor" stroke-width="4"></circle>
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
</span>
|
||||
@else
|
||||
<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">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
||||
stroke="currentColor" stroke-width="4"></circle>
|
||||
|
|
@ -80,7 +80,7 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
|
|||
@endif
|
||||
@if ($isValidating)
|
||||
<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"
|
||||
viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<h2>Project: {{ data_get($project, 'name') }}</h2>
|
||||
<div class="pt-0 pb-3">{{ data_get($project, 'description') }}</div>
|
||||
@forelse ($project->environments as $environment)
|
||||
<a class="box group"
|
||||
<a class="coolbox group"
|
||||
href="{{ route('shared-variables.environment.show', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
|
|
|||
|
|
@ -8,19 +8,19 @@
|
|||
<div class="subtitle">Set Team / Project / Environment wide variables.</div>
|
||||
|
||||
<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="box-title">Team wide</div>
|
||||
<div class="box-description">Usable for all resources in a team.</div>
|
||||
</div>
|
||||
</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="box-title">Project wide</div>
|
||||
<div class="box-description">Usable for all resources in a project.</div>
|
||||
</div>
|
||||
</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="box-title">Environment wide</div>
|
||||
<div class="box-description">Usable for all resources in an environment.</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<div class="subtitle">List of your projects.</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
@forelse ($projects as $project)
|
||||
<a class="box group"
|
||||
<a class="coolbox group"
|
||||
href="{{ route('shared-variables.project.show', ['project_uuid' => data_get($project, 'uuid')]) }}">
|
||||
<div class="flex flex-col justify-center mx-6 ">
|
||||
<div class="box-title">{{ $project->name }}</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
</svg>
|
||||
<span>You must complete this step before you can use this source!</span>
|
||||
</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
|
||||
</a>
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<div class="subtitle">S3 storages for backups.</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
|
||||
@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="box-title">
|
||||
{{ $storage->name }}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<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',
|
||||
'dark:border-coolgray-300' => data_get($deployment, 'status') === 'queued',
|
||||
'dark:border-yellow-500' =>
|
||||
'dark:border-warning-500' =>
|
||||
data_get($deployment, 'status') === 'in_progress',
|
||||
])>
|
||||
<div class="flex flex-col mx-6">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<div class="flex flex-wrap gap-2 ">
|
||||
@forelse ($tags as $oneTag)
|
||||
<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>
|
||||
@empty
|
||||
<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">
|
||||
@if (isset($applications) && count($applications) > 0)
|
||||
@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="box-title">
|
||||
{{ $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
|
||||
@if (isset($services) && count($services) > 0)
|
||||
@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="box-title">
|
||||
{{ $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">
|
||||
@foreach ($deployments as $deployment)
|
||||
<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',
|
||||
'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="font-bold dark:text-white">
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">Cancel
|
|||
modalOpen: false,
|
||||
showProgress: false,
|
||||
currentStatus: '',
|
||||
checkHealthInterval: null,
|
||||
checkIfIamDeadInterval: null,
|
||||
healthCheckAttempts: 0,
|
||||
startTime: null,
|
||||
confirmed() {
|
||||
this.showProgress = true;
|
||||
this.$wire.$call('upgrade')
|
||||
|
|
@ -102,43 +106,78 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">Cancel
|
|||
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() {
|
||||
if (checkHealthInterval) return true;
|
||||
if (this.checkHealthInterval) return true;
|
||||
this.healthCheckAttempts = 0;
|
||||
this.startTime = Date.now();
|
||||
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')
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
this.currentStatus =
|
||||
'Coolify is back online. Reloading this page (you can manually reload if its not done automatically)...';
|
||||
if (checkHealthInterval) clearInterval(
|
||||
checkHealthInterval);
|
||||
'Coolify is back online. Reloading this page in 5 seconds...';
|
||||
if (this.checkHealthInterval) {
|
||||
clearInterval(this.checkHealthInterval);
|
||||
this.checkHealthInterval = null;
|
||||
}
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 5000)
|
||||
} else {
|
||||
this.currentStatus =
|
||||
"Waiting for Coolify to come back from the dead..."
|
||||
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
|
||||
.healthCheckAttempts);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Health check failed:', error);
|
||||
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
|
||||
.healthCheckAttempts);
|
||||
});
|
||||
}, 2000);
|
||||
},
|
||||
upgrade() {
|
||||
if (checkIfIamDeadInterval || this.$wire.showProgress) return true;
|
||||
this.currentStatus = 'Pulling new images and updating Coolify.';
|
||||
checkIfIamDeadInterval = setInterval(() => {
|
||||
if (this.checkIfIamDeadInterval || this.showProgress) return true;
|
||||
this.currentStatus = 'Update in progress. Pulling new images and preparing to restart Coolify...';
|
||||
this.checkIfIamDeadInterval = setInterval(() => {
|
||||
fetch('/api/health')
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
this.currentStatus = "Waiting for the update process..."
|
||||
} else {
|
||||
this.currentStatus =
|
||||
"Update done, restarting Coolify & waiting until it is revived!"
|
||||
if (checkIfIamDeadInterval) clearInterval(
|
||||
checkIfIamDeadInterval);
|
||||
"Update in progress. Pulling new images and preparing to restart Coolify..."
|
||||
} else {
|
||||
this.currentStatus = "Coolify is restarting with the new version..."
|
||||
if (this.checkIfIamDeadInterval) {
|
||||
clearInterval(this.checkIfIamDeadInterval);
|
||||
this.checkIfIamDeadInterval = null;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
|
||||
@forelse ($sources as $source)
|
||||
@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')]) }}">
|
||||
{{-- <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">
|
||||
|
|
|
|||
186
tests/Unit/CheckForUpdatesJobTest.php
Normal file
186
tests/Unit/CheckForUpdatesJobTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -153,3 +153,39 @@
|
|||
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
149
tests/Unit/ServiceApplicationPrerequisitesTest.php
Normal file
149
tests/Unit/ServiceApplicationPrerequisitesTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
// Simulate the command sequence from StopProxy
|
||||
$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',
|
||||
'# Wait for container to be fully removed',
|
||||
'for i in {1..10}; do',
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
$commandsString = implode("\n", $commands);
|
||||
|
||||
// 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('for i in {1..10}; do')
|
||||
->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
|
||||
|
||||
$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',
|
||||
];
|
||||
|
||||
|
|
@ -54,9 +54,9 @@
|
|||
// Verify that stop command includes the timeout parameter
|
||||
|
||||
$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 () {
|
||||
|
|
|
|||
132
tests/Unit/UpdateCoolifyTest.php
Normal file
132
tests/Unit/UpdateCoolifyTest.php
Normal 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');
|
||||
}
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.451"
|
||||
"version": "4.0.0-beta.452"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.452"
|
||||
"version": "4.0.0-beta.453"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.12"
|
||||
|
|
|
|||
Loading…
Reference in a new issue