v4.0.0-beta.460 (#7768)

This commit is contained in:
Andras Bacsai 2025-12-31 11:56:57 +01:00 committed by GitHub
commit f488bd9a32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 766 additions and 450 deletions

50
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View file

@ -55,6 +55,14 @@ public function handle(Application $application, bool $previewDeployments = fals
return $e->getMessage();
}
}
// Reset restart tracking when application is manually stopped
$application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
}

View file

@ -5,7 +5,6 @@
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@ -270,10 +269,9 @@ private function add_custom_keydb()
return;
}
$filename = 'keydb.conf';
Storage::disk('local')->put("tmp/keydb.conf_{$this->database->uuid}", $this->database->keydb_conf);
$path = Storage::path("tmp/keydb.conf_{$this->database->uuid}");
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}");
$content = $this->database->keydb_conf;
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
private function buildStartCommand(): string

View file

@ -5,7 +5,6 @@
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
@ -316,9 +315,8 @@ private function add_custom_redis()
return;
}
$filename = 'redis.conf';
Storage::disk('local')->put("tmp/redis.conf_{$this->database->uuid}", $this->database->redis_conf);
$path = Storage::path("tmp/redis.conf_{$this->database->uuid}");
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
Storage::disk('local')->delete("tmp/redis.conf_{$this->database->uuid}");
$content = $this->database->redis_conf;
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
}

View file

@ -28,6 +28,13 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$this->stopContainer($database, $database->uuid, 30);
// Reset restart tracking when database is manually stopped
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
if ($dockerCleanup) {
CleanupDocker::dispatch($server, false, false);
}

View file

@ -29,7 +29,8 @@ public function handle(ProxyStatusChanged $event)
$server->proxy->set('status', $status);
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
$versionCheckDispatched = false;
if ($status === 'running') {
$server->setupDefaultRedirect();
$server->setupDynamicProxyConfiguration();
@ -40,7 +41,9 @@ public function handle(ProxyStatusChanged $event)
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
$traefikVersions = get_traefik_versions();
if ($traefikVersions !== null) {
// Version check job will dispatch ProxyStatusChangedUI when complete
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
$versionCheckDispatched = true;
} else {
Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [
'server_id' => $server->id,
@ -49,6 +52,13 @@ public function handle(ProxyStatusChanged $event)
}
}
}
// Only dispatch UI refresh if version check wasn't dispatched
// (version check job handles its own UI refresh with updated version data)
if (! $versionCheckDispatched) {
ProxyStatusChangedUI::dispatch($server->team_id);
}
if ($status === 'created') {
instant_remote_process([
'docker rm -f coolify-proxy',

View file

@ -95,7 +95,7 @@ public function submit()
]);
}
}
$this->redirect(route('destination.show', $docker->uuid));
redirectRoute($this, 'destination.show', [$docker->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -1314,10 +1314,10 @@ private function completeResourceCreation()
'server_id' => $this->selectedServerId,
];
$this->redirect(route('project.resource.create', [
redirectRoute($this, 'project.resource.create', [
'project_uuid' => $this->selectedProjectUuid,
'environment_uuid' => $this->selectedEnvironmentUuid,
] + $queryParams));
] + $queryParams);
}
}
@ -1336,6 +1336,42 @@ public function cancelResourceSelection()
$this->autoOpenResource = null;
}
public function goBack()
{
// From Environment Selection → go back to Project (if multiple) or further
if ($this->selectedProjectUuid !== null) {
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableProjects) > 1) {
return; // Stop here - user can choose a project
}
}
// From Project Selection → go back to Destination (if multiple) or further
if ($this->selectedDestinationUuid !== null) {
$this->selectedDestinationUuid = null;
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableDestinations) > 1) {
return; // Stop here - user can choose a destination
}
}
// From Destination Selection → go back to Server (if multiple) or cancel
if ($this->selectedServerId !== null) {
$this->selectedServerId = null;
$this->selectedDestinationUuid = null;
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableServers) > 1) {
return; // Stop here - user can choose a server
}
}
// All previous steps were auto-selected, cancel entirely
$this->cancelResourceSelection();
}
public function getFilteredCreatableItemsProperty()
{
$query = strtolower(trim($this->searchQuery));

View file

@ -37,7 +37,7 @@ public function delete($password)
refreshSession();
return redirect()->route('team.index');
return redirectRoute($this, 'team.index');
}
public function render()

View file

@ -558,8 +558,11 @@ public function loadComposeFile($isInit = false, $showToast = true, ?string $res
$this->dispatch('refreshStorages');
$this->dispatch('refreshEnvs');
} catch (\Throwable $e) {
$this->application->docker_compose_location = $this->initialDockerComposeLocation;
$this->application->save();
// Refresh model to get restored values from Application::loadComposeFile
$this->application->refresh();
// Sync restored values back to component properties for UI update
$this->syncData();
return handleError($e, $this);
} finally {
@ -936,73 +939,6 @@ public function downloadConfig()
]);
}
private function updateServiceEnvironmentVariables()
{
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
foreach ($domains as $serviceName => $service) {
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
$domain = data_get($service, 'domain');
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
->delete();
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
->delete();
if ($domain) {
// Create or update SERVICE_FQDN_ and SERVICE_URL_ variables
$fqdn = Url::fromString($domain);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$urlValue = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($path !== '/') {
$urlValue = $urlValue.$path;
}
$fqdnValue = str($domain)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
// Create/update SERVICE_FQDN_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
// Create/update SERVICE_URL_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
// Create/update port-specific variables if port exists
if (filled($port)) {
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
}
}
}
}
public function getDetectedPortInfoProperty(): ?array
{
$detectedPort = $this->application->detectPortFromEnvironment();

View file

@ -66,7 +66,7 @@ public function rollbackImage($commit)
return;
}
return redirect()->route('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $deployment_uuid,

View file

@ -39,7 +39,7 @@ public function delete()
if ($environment->isEmpty()) {
$environment->delete();
return redirect()->route('project.show', parameters: ['project_uuid' => $this->parameters['project_uuid']]);
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
}
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");

View file

@ -35,7 +35,7 @@ public function delete()
if ($project->isEmpty()) {
$project->delete();
return redirect()->route('project.index');
return redirectRoute($this, 'project.index');
}
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");

View file

@ -63,7 +63,7 @@ public function submit()
{
try {
$this->syncData(true);
$this->redirectRoute('project.environment.edit', [
redirectRoute($this, 'project.environment.edit', [
'environment_uuid' => $this->environment->uuid,
'project_uuid' => $this->project->uuid,
]);

View file

@ -154,7 +154,7 @@ public function submit()
'fqdn' => $fqdn,
]);
return redirect()->route('project.application.configuration', [
return redirectRoute($this, 'project.application.configuration', [
'application_uuid' => $application->uuid,
'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid,

View file

@ -16,6 +16,6 @@ public function createEmptyProject()
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
}
}

View file

@ -100,7 +100,7 @@ public function delete($password)
$this->database->delete();
$this->dispatch('success', 'Database deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
return redirectRoute($this, 'project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -164,7 +164,7 @@ public function convertToApplication()
$serviceDatabase->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
return redirectRoute($this, 'project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -111,7 +111,7 @@ public function delete($password)
$this->docker_cleanup
);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->projectUuid,
'environment_uuid' => $this->environmentUuid,
]);

View file

@ -97,7 +97,7 @@ public function redeploy(int $network_id, int $server_id)
return;
}
return redirect()->route('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => data_get($this->resource, 'environment.project.uuid'),
'application_uuid' => data_get($this->resource, 'uuid'),
'deployment_uuid' => $deployment_uuid,

View file

@ -57,7 +57,14 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'");
// Add sudo for non-root users to access Docker socket
$dockerCommand = "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'";
if ($server->isNonRoot()) {
$dockerCommand = "sudo {$dockerCommand}";
}
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.

View file

@ -48,7 +48,7 @@ public function submit()
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
]);
@ -59,7 +59,7 @@ public function submit()
public function navigateToEnvironment($projectUuid, $environmentUuid)
{
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $projectUuid,
'environment_uuid' => $environmentUuid,
]);

View file

@ -114,7 +114,7 @@ private function validatePrivateKey()
private function redirectAfterCreation(PrivateKey $privateKey)
{
return $this->from === 'server'
? redirect()->route('dashboard')
: redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
? redirectRoute($this, 'dashboard')
: redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
}
}

View file

@ -107,7 +107,7 @@ public function delete()
$this->private_key->safeDelete();
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
return redirect()->route('security.private-key.index');
return redirectRoute($this, 'security.private-key.index');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {

View file

@ -46,7 +46,7 @@ public function delete($password)
$this->server->team_id
);
return redirect()->route('server.index');
return redirectRoute($this, 'server.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -567,10 +567,10 @@ public function submit()
]);
refreshSession();
return $this->redirect(route('server.show', $server->uuid));
return redirectRoute($this, 'server.show', [$server->uuid]);
}
return redirect()->route('server.show', $server->uuid);
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -128,7 +128,7 @@ public function submit()
$server->settings->is_build_server = $this->is_build_server;
$server->settings->save();
return redirect()->route('server.show', $server->uuid);
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -58,7 +58,7 @@ public function createGitHubApp()
session(['from' => session('from') + ['source_id' => $github_app->id]]);
}
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -116,7 +116,7 @@ public function submit()
$this->storage->testConnection();
$this->storage->save();
return redirect()->route('storage.show', $this->storage->uuid);
return redirectRoute($this, 'storage.show', [$this->storage->uuid]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
// return handleError($e, $this);

View file

@ -37,7 +37,7 @@ public function submit()
auth()->user()->teams()->attach($team, ['role' => 'admin']);
refreshSession($team);
return redirect()->route('team.index');
return redirectRoute($this, 'team.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -1584,6 +1584,11 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
try {
$composeFileContent = instant_remote_process($commands, $this->destination->server);
} catch (\Exception $e) {
// Restore original values on failure only
$this->docker_compose_location = $initialDockerComposeLocation;
$this->base_directory = $initialBaseDirectory;
$this->save();
if (str($e->getMessage())->contains('No such file')) {
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
@ -1595,9 +1600,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
}
throw new \RuntimeException($e->getMessage());
} finally {
$this->docker_compose_location = $initialDockerComposeLocation;
$this->base_directory = $initialBaseDirectory;
$this->save();
// Cleanup only - restoration happens in catch block
$commands = collect([
"rm -rf /tmp/{$uuid}",
]);
@ -1643,6 +1646,11 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
'initialDockerComposeLocation' => $this->docker_compose_location,
];
} else {
// Restore original values before throwing
$this->docker_compose_location = $initialDockerComposeLocation;
$this->base_directory = $initialBaseDirectory;
$this->save();
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}

View file

@ -682,9 +682,16 @@ public function getCpuMetrics(int $mins = 5)
}
$cpu = json_decode($cpu, true);
return collect($cpu)->map(function ($metric) {
$metrics = collect($cpu)->map(function ($metric) {
return [(int) $metric['time'], (float) $metric['percent']];
});
})->toArray();
// Downsample for intervals > 60 minutes to prevent browser freeze
if ($mins > 60 && count($metrics) > 1000) {
$metrics = $this->downsampleLTTB($metrics, 1000);
}
return collect($metrics);
}
}
@ -702,16 +709,99 @@ public function getMemoryMetrics(int $mins = 5)
throw new \Exception($error);
}
$memory = json_decode($memory, true);
$parsedCollection = collect($memory)->map(function ($metric) {
$metrics = collect($memory)->map(function ($metric) {
$usedPercent = $metric['usedPercent'] ?? 0.0;
return [(int) $metric['time'], (float) $usedPercent];
});
})->toArray();
return $parsedCollection->toArray();
// Downsample for intervals > 60 minutes to prevent browser freeze
if ($mins > 60 && count($metrics) > 1000) {
$metrics = $this->downsampleLTTB($metrics, 1000);
}
return collect($metrics);
}
}
/**
* Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm.
* This preserves the visual shape of the data better than simple averaging.
*
* @param array $data Array of [timestamp, value] pairs
* @param int $threshold Target number of points
* @return array Downsampled data
*/
private function downsampleLTTB(array $data, int $threshold): array
{
$dataLength = count($data);
// Return unchanged if threshold >= data length, or if threshold <= 2
// (threshold <= 2 would cause division by zero in bucket calculation)
if ($threshold >= $dataLength || $threshold <= 2) {
return $data;
}
$sampled = [];
$sampled[] = $data[0]; // Always keep first point
$bucketSize = ($dataLength - 2) / ($threshold - 2);
$a = 0; // Index of previous selected point
for ($i = 0; $i < $threshold - 2; $i++) {
// Calculate bucket range
$bucketStart = (int) floor(($i + 1) * $bucketSize) + 1;
$bucketEnd = (int) floor(($i + 2) * $bucketSize) + 1;
$bucketEnd = min($bucketEnd, $dataLength - 1);
// Calculate average point for next bucket (used as reference)
$nextBucketStart = (int) floor(($i + 2) * $bucketSize) + 1;
$nextBucketEnd = (int) floor(($i + 3) * $bucketSize) + 1;
$nextBucketEnd = min($nextBucketEnd, $dataLength - 1);
$avgX = 0;
$avgY = 0;
$nextBucketCount = $nextBucketEnd - $nextBucketStart + 1;
if ($nextBucketCount > 0) {
for ($j = $nextBucketStart; $j <= $nextBucketEnd; $j++) {
$avgX += $data[$j][0];
$avgY += $data[$j][1];
}
$avgX /= $nextBucketCount;
$avgY /= $nextBucketCount;
}
// Find point in current bucket with largest triangle area
$maxArea = -1;
$maxAreaIndex = $bucketStart;
$pointAX = $data[$a][0];
$pointAY = $data[$a][1];
for ($j = $bucketStart; $j <= $bucketEnd; $j++) {
// Triangle area calculation
$area = abs(
($pointAX - $avgX) * ($data[$j][1] - $pointAY) -
($pointAX - $data[$j][0]) * ($avgY - $pointAY)
) * 0.5;
if ($area > $maxArea) {
$maxArea = $area;
$maxAreaIndex = $j;
}
}
$sampled[] = $data[$maxAreaIndex];
$a = $maxAreaIndex;
}
$sampled[] = $data[$dataLength - 1]; // Always keep last point
return $sampled;
}
public function getDiskUsage(): ?string
{
return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false);

View file

@ -2934,6 +2934,23 @@ function wireNavigate(): string
}
}
/**
* Redirect to a named route with SPA navigation support.
* Automatically uses wire:navigate when is_wire_navigate_enabled is true.
*/
function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed
{
$navigate = true;
try {
$navigate = instanceSettings()->is_wire_navigate_enabled ?? true;
} catch (\Exception $e) {
$navigate = true;
}
return $component->redirectRoute($name, $parameters, navigate: $navigate);
}
function getHelperVersion(): string
{
$settings = instanceSettings();

View file

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

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.459"
"version": "4.0.0-beta.460"
},
"nightly": {
"version": "4.0.0-beta.460"
"version": "4.0.0-beta.461"
},
"helper": {
"version": "1.0.12"

View file

@ -6,11 +6,6 @@
href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}">Advanced
</a>
@endif
@if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel)
<a class="menu-item {{ $activeMenu === 'swarm' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.swarm', ['server_uuid' => $server->uuid]) }}">Swarm
</a>
@endif
@if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer())
<a class="menu-item {{ $activeMenu === 'sentinel' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}">Sentinel
@ -20,7 +15,8 @@
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}">Private Key
</a>
@if ($server->hetzner_server_id)
<a class="menu-item {{ $activeMenu === 'cloud-provider-token' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
<a class="menu-item {{ $activeMenu === 'cloud-provider-token' ? 'menu-item-active' : '' }}"
{{ wireNavigate() }}
href="{{ route('server.cloud-provider-token', ['server_uuid' => $server->uuid]) }}">Hetzner Token
</a>
@endif
@ -45,6 +41,11 @@
<a class="menu-item {{ $activeMenu === 'metrics' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.charts', ['server_uuid' => $server->uuid]) }}">Metrics</a>
@endif
@if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel)
<a class="menu-item {{ $activeMenu === 'swarm' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.swarm', ['server_uuid' => $server->uuid]) }}">Swarm (experimental)
</a>
@endif
@if (!$server->isLocalhost())
<a class="menu-item {{ $activeMenu === 'danger' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.delete', ['server_uuid' => $server->uuid]) }}">Danger</a>

View file

@ -17,7 +17,7 @@
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 1,
'bg-warning/20 border-warning': currentStep === 1,
'bg-black/20 border-black dark:bg-warning/20 dark:border-warning': currentStep === 1,
'border-neutral-400 dark:border-coolgray-300': currentStep < 1
}">
<template x-if="currentStep > 1">
@ -26,7 +26,7 @@
</svg>
</template>
<template x-if="currentStep === 1">
<svg class="size-4 text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<svg class="size-4 text-black dark:text-warning animate-spin" 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>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@ -38,7 +38,7 @@
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 1,
'text-warning': currentStep === 1,
'text-black dark:text-warning': currentStep === 1,
'text-neutral-500 dark:text-neutral-400': currentStep < 1
}">Preparing</span>
</div>
@ -52,7 +52,7 @@
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 2,
'bg-warning/20 border-warning': currentStep === 2,
'bg-black/20 border-black dark:bg-warning/20 dark:border-warning': currentStep === 2,
'border-neutral-400 dark:border-coolgray-300': currentStep < 2
}">
<template x-if="currentStep > 2">
@ -61,7 +61,7 @@
</svg>
</template>
<template x-if="currentStep === 2">
<svg class="size-4 text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<svg class="size-4 text-black dark:text-warning animate-spin" 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>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@ -73,7 +73,7 @@
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 2,
'text-warning': currentStep === 2,
'text-black dark:text-warning': currentStep === 2,
'text-neutral-500 dark:text-neutral-400': currentStep < 2
}">Helper</span>
</div>
@ -87,7 +87,7 @@
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 3,
'bg-warning/20 border-warning': currentStep === 3,
'bg-black/20 border-black dark:bg-warning/20 dark:border-warning': currentStep === 3,
'border-neutral-400 dark:border-coolgray-300': currentStep < 3
}">
<template x-if="currentStep > 3">
@ -96,7 +96,7 @@
</svg>
</template>
<template x-if="currentStep === 3">
<svg class="size-4 text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<svg class="size-4 text-black dark:text-warning animate-spin" 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>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@ -108,7 +108,7 @@
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 3,
'text-warning': currentStep === 3,
'text-black dark:text-warning': currentStep === 3,
'text-neutral-500 dark:text-neutral-400': currentStep < 3
}">Image</span>
</div>
@ -122,7 +122,7 @@
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 4,
'bg-warning/20 border-warning': currentStep === 4,
'bg-black/20 border-black dark:bg-warning/20 dark:border-warning': currentStep === 4,
'border-neutral-400 dark:border-coolgray-300': currentStep < 4
}">
<template x-if="currentStep > 4">
@ -131,7 +131,7 @@
</svg>
</template>
<template x-if="currentStep === 4">
<svg class="size-4 text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<svg class="size-4 text-black dark:text-warning animate-spin" 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>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@ -143,7 +143,7 @@
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 4,
'text-warning': currentStep === 4,
'text-black dark:text-warning': currentStep === 4,
'text-neutral-500 dark:text-neutral-400': currentStep < 4
}">Restart</span>
</div>

View file

@ -323,7 +323,7 @@ class="mt-2 bg-white dark:bg-coolgray-100 rounded-lg shadow-xl ring-1 ring-neutr
<div class="mb-4" x-init="selectedIndex = -1">
<div class="flex items-center gap-3 mb-3">
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
@click="$wire.goBack()"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
@ -398,7 +398,7 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
<div class="mb-4" x-init="selectedIndex = -1">
<div class="flex items-center gap-3 mb-3">
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
@click="$wire.goBack()"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
@ -467,7 +467,7 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
<div class="mb-4" x-init="selectedIndex = -1">
<div class="flex items-center gap-3 mb-3">
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
@click="$wire.goBack()"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
@ -542,7 +542,7 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
<div class="mb-4" x-init="selectedIndex = -1">
<div class="flex items-center gap-3 mb-3">
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
@click="$wire.goBack()"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">

View file

@ -12,7 +12,7 @@
<div>{{ $application->compose_parsing_version }}</div>
@endif
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
@if ($application->build_pack === 'dockercompose')
@if ($buildPack === 'dockercompose')
<x-forms.button canGate="update" :canResource="$application" wire:target='initLoadingCompose'
x-on:click="$wire.dispatch('loadCompose', false)">
{{ $application->docker_compose_raw ? 'Reload Compose File' : 'Load Compose File' }}
@ -36,7 +36,7 @@
<option value="dockerfile">Dockerfile</option>
<option value="dockercompose">Docker Compose</option>
</x-forms.select>
@if ($application->settings->is_static || $application->build_pack === 'static')
@if ($isStatic || $buildPack === 'static')
<x-forms.select x-bind:disabled="!canUpdate" id="staticImage" label="Static Image" required>
<option value="nginx:alpine">nginx:alpine</option>
<option disabled value="apache:alpine">apache:alpine</option>
@ -44,12 +44,11 @@
@endif
</div>
@if ($application->build_pack === 'dockercompose')
@if ($buildPack === 'dockercompose')
@if (
!is_null($parsedServices) &&
!is_null($parsedServices) &&
count($parsedServices) > 0 &&
!$application->settings->is_raw_compose_deployment_enabled
)
!$application->settings->is_raw_compose_deployment_enabled)
<h3 class="pt-6">Domains</h3>
@foreach (data_get($parsedServices, 'services') as $serviceName => $service)
@if (!isDatabaseImage(data_get($service, 'image')))
@ -71,7 +70,7 @@
</div>
@endif
@if ($application->settings->is_static || $application->build_pack === 'static')
@if ($isStatic || $buildPack === 'static')
<x-forms.textarea id="customNginxConfiguration"
placeholder="Empty means default configuration will be used." label="Custom Nginx Configuration"
helper="You can add custom Nginx configuration here." x-bind:disabled="!canUpdate" />
@ -80,11 +79,11 @@
buttonTitle="Generate Default Nginx Configuration" buttonFullWidth
submitAction="generateNginxConfiguration('{{ $application->settings->is_spa ? 'spa' : 'static' }}')"
:actions="[
'This will overwrite your current custom Nginx configuration.',
'The default configuration will be generated based on your application type (' .
($application->settings->is_spa ? 'SPA' : 'static') .
').',
]" />
'This will overwrite your current custom Nginx configuration.',
'The default configuration will be generated based on your application type (' .
($application->settings->is_spa ? 'SPA' : 'static') .
').',
]" />
@endcan
@endif
<div class="w-96 pb-6">
@ -93,13 +92,13 @@
helper="If your application is a static site or the final build assets should be served as a static site, enable this."
x-bind:disabled="!canUpdate" />
@endif
@if ($application->settings->is_static && $application->build_pack !== 'static')
@if ($isStatic && $buildPack !== 'static')
<x-forms.checkbox label="Is it a SPA (Single Page Application)?"
helper="If your application is a SPA, enable this." id="isSpa" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
@endif
</div>
@if ($application->build_pack !== 'dockercompose')
@if ($buildPack !== 'dockercompose')
<div class="flex items-end gap-2">
@if ($application->settings->is_container_label_readonly_enabled == false)
<x-forms.input placeholder="https://coolify.io" wire:model="fqdn" label="Domains" readonly
@ -156,7 +155,7 @@
</div>
@endif
@if ($application->build_pack !== 'dockercompose')
@if ($buildPack !== 'dockercompose')
<div class="flex items-center gap-2 pt-8">
<h3>Docker Registry</h3>
@if ($application->build_pack !== 'dockerimage' && !$application->destination->server->isSwarm())
@ -166,8 +165,9 @@
</div>
@if ($application->destination->server->isSwarm())
@if ($application->build_pack !== 'dockerimage')
<div>Docker Swarm requires the image to be available in a registry. More info <a class="underline"
href="https://coolify.io/docs/knowledge-base/docker/registry" target="_blank">here</a>.</div>
<div>Docker Swarm requires the image to be available in a registry. More info <a
class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
target="_blank">here</a>.</div>
@endif
@endif
<div class="flex flex-col gap-2 xl:flex-row">
@ -179,19 +179,19 @@
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@else
<x-forms.input id="dockerRegistryImageName" label="Docker Image" x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageName" label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageTag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@endif
@else
@if (
$application->destination->server->isSwarm() ||
$application->destination->server->isSwarm() ||
$application->additional_servers->count() > 0 ||
$application->settings->is_build_server_enabled
)
<x-forms.input id="dockerRegistryImageName" required label="Docker Image" placeholder="Required!"
x-bind:disabled="!canUpdate" />
$application->settings->is_build_server_enabled)
<x-forms.input id="dockerRegistryImageName" required label="Docker Image"
placeholder="Required!" x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageTag"
helper="If set, it will tag the built image with this tag too. <br><br>Example: If you set it to 'latest', it will push the image with the commit sha tag + with the latest tag."
placeholder="Empty means latest will be used." label="Docker Image Tag"
@ -199,9 +199,10 @@
@else
<x-forms.input id="dockerRegistryImageName"
helper="Empty means it won't push the image to a docker registry. Pre-tag the image with your registry url if you want to push it to a private registry (default: Dockerhub). <br><br>Example: ghcr.io/myimage"
placeholder="Empty means it won't push the image to a docker registry." label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageTag" placeholder="Empty means only push commit sha tag."
placeholder="Empty means it won't push the image to a docker registry."
label="Docker Image" x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageTag"
placeholder="Empty means only push commit sha tag."
helper="If set, it will tag the built image with this tag too. <br><br>Example: If you set it to 'latest', it will push the image with the commit sha tag + with the latest tag."
label="Docker Image Tag" x-bind:disabled="!canUpdate" />
@endif
@ -217,7 +218,7 @@
id="customDockerRunOptions" label="Custom Docker Options" x-bind:disabled="!canUpdate" />
@else
@if ($application->could_set_build_commands())
@if ($application->build_pack === 'nixpacks')
@if ($buildPack === 'nixpacks')
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
id="installCommand" label="Install Command" x-bind:disabled="!canUpdate" />
@ -235,83 +236,12 @@
@endif
<div class="flex flex-col gap-2 pt-6 pb-10">
@if ($application->build_pack === 'dockercompose')
<div class="flex flex-col gap-2" @can('update', $application) x-init="$wire.dispatch('loadCompose', true)" @endcan>
<div x-data="{
baseDir: '{{ $application->base_directory }}',
composeLocation: '{{ $application->docker_compose_location }}',
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
path = path.replace(/\/+$/, '');
if (!path.startsWith('/')) {
path = '/' + path;
}
return path;
},
normalizeBaseDir() {
this.baseDir = this.normalizePath(this.baseDir);
},
normalizeComposeLocation() {
this.composeLocation = this.normalizePath(this.composeLocation);
}
}" class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="/" wire:model.defer="baseDirectory"
label="Base Directory" helper="Directory to use as root. Useful for monorepos."
x-model="baseDir" @blur="normalizeBaseDir()" />
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="/docker-compose.yaml"
wire:model.defer="dockerComposeLocation" label="Docker Compose Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->docker_compose_location, '/') }}</span>"
x-model="composeLocation" @blur="normalizeComposeLocation()" />
</div>
<div class="w-96">
<x-forms.checkbox instantSave id="isPreserveRepositoryEnabled"
label="Preserve Repository During Deployment"
helper="Git repository (based on the base directory settings) will be copied to the deployment directory."
x-bind:disabled="shouldDisable()" />
</div>
<div class="pt-4">The following commands are for advanced use cases.
Only
modify them if you
know what are
you doing.</div>
<div class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="docker compose build"
id="dockerComposeCustomBuildCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose build</span>"
label="Custom Build Command" />
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="docker compose up -d"
id="dockerComposeCustomStartCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose up -d</span>"
label="Custom Start Command" />
</div>
@if ($this->dockerComposeCustomBuildCommand)
<div wire:key="docker-compose-build-preview">
<x-forms.input readonly value="{{ $this->dockerComposeBuildCommandPreview }}"
label="Final Build Command (Preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->dockerComposeCustomStartCommand)
<div wire:key="docker-compose-start-preview">
<x-forms.input readonly value="{{ $this->dockerComposeStartCommandPreview }}"
label="Final Start Command (Preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
<div class="pt-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
placeholder="services/api/**" id="watchPaths" label="Watch Paths"
x-bind:disabled="shouldDisable()" />
</div>
@endif
</div>
@else
@if ($buildPack === 'dockercompose')
<div class="flex flex-col gap-2"
@can('update', $application) x-init="$wire.dispatch('loadCompose', true)" @endcan>
<div x-data="{
baseDir: '{{ $application->base_directory }}',
dockerfileLocation: '{{ $application->dockerfile_location }}',
baseDir: @entangle('baseDirectory'),
composeLocation: @entangle('dockerComposeLocation'),
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
@ -324,65 +254,145 @@
normalizeBaseDir() {
this.baseDir = this.normalizePath(this.baseDir);
},
normalizeDockerfileLocation() {
this.dockerfileLocation = this.normalizePath(this.dockerfileLocation);
normalizeComposeLocation() {
this.composeLocation = this.normalizePath(this.composeLocation);
}
}" class="flex flex-col gap-2 xl:flex-row">
<x-forms.input placeholder="/" wire:model.defer="baseDirectory" label="Base Directory"
helper="Directory to use as root. Useful for monorepos." x-bind:disabled="!canUpdate"
x-model="baseDir" @blur="normalizeBaseDir()" />
@if ($application->build_pack === 'dockerfile' && !$application->dockerfile)
<x-forms.input placeholder="/Dockerfile" wire:model.defer="dockerfileLocation" label="Dockerfile Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->dockerfile_location, '/') }}</span>"
x-bind:disabled="!canUpdate" x-model="dockerfileLocation" @blur="normalizeDockerfileLocation()" />
@endif
@if ($application->build_pack === 'dockerfile')
<x-forms.input id="dockerfileTargetBuild" label="Docker Build Stage Target"
helper="Useful if you have multi-staged dockerfile." x-bind:disabled="!canUpdate" />
@endif
@if ($application->could_set_build_commands())
@if ($application->settings->is_static)
<x-forms.input placeholder="/dist" id="publishDirectory" label="Publish Directory" required
x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="/" id="publishDirectory" label="Publish Directory"
x-bind:disabled="!canUpdate" />
@endif
@endif
}" class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="/"
label="Base Directory"
helper="Directory to use as root. Useful for monorepos." x-model="baseDir"
@blur="normalizeBaseDir()" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="/docker-compose.yaml"
label="Docker Compose Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($baseDirectory . $dockerComposeLocation, '/') }}</span>"
x-model="composeLocation" @blur="normalizeComposeLocation()" />
</div>
<div class="w-96">
<x-forms.checkbox instantSave id="isPreserveRepositoryEnabled"
label="Preserve Repository During Deployment"
helper="Git repository (based on the base directory settings) will be copied to the deployment directory."
x-bind:disabled="shouldDisable()" />
</div>
<div class="pt-4">The following commands are for advanced use cases.
Only
modify them if you
know what are
you doing.</div>
<div class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose build" id="dockerComposeCustomBuildCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose build</span>"
label="Custom Build Command" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose up -d" id="dockerComposeCustomStartCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose up -d</span>"
label="Custom Start Command" />
</div>
@if ($this->dockerComposeCustomBuildCommand)
<div wire:key="docker-compose-build-preview">
<x-forms.input readonly value="{{ $this->dockerComposeBuildCommandPreview }}"
label="Final Build Command (Preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->dockerComposeCustomStartCommand)
<div wire:key="docker-compose-start-preview">
<x-forms.input readonly value="{{ $this->dockerComposeStartCommandPreview }}"
label="Final Start Command (Preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
<div class="pb-4">
<div class="pt-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
placeholder="src/pages/**" id="watchPaths" label="Watch Paths"
x-bind:disabled="!canUpdate" />
placeholder="services/api/**" id="watchPaths" label="Watch Paths"
x-bind:disabled="shouldDisable()" />
</div>
@endif
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k --hostname=myapp"
id="customDockerRunOptions" label="Custom Docker Options" x-bind:disabled="!canUpdate" />
</div>
@else
<div x-data="{
baseDir: '{{ $application->base_directory }}',
dockerfileLocation: '{{ $application->dockerfile_location }}',
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
path = path.replace(/\/+$/, '');
if (!path.startsWith('/')) {
path = '/' + path;
}
return path;
},
normalizeBaseDir() {
this.baseDir = this.normalizePath(this.baseDir);
},
normalizeDockerfileLocation() {
this.dockerfileLocation = this.normalizePath(this.dockerfileLocation);
}
}" class="flex flex-col gap-2 xl:flex-row">
<x-forms.input placeholder="/" wire:model.defer="baseDirectory"
label="Base Directory" helper="Directory to use as root. Useful for monorepos."
x-bind:disabled="!canUpdate" x-model="baseDir" @blur="normalizeBaseDir()" />
@if ($buildPack === 'dockerfile' && !$application->dockerfile)
<x-forms.input placeholder="/Dockerfile" wire:model.defer="dockerfileLocation"
label="Dockerfile Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->dockerfile_location, '/') }}</span>"
x-bind:disabled="!canUpdate" x-model="dockerfileLocation"
@blur="normalizeDockerfileLocation()" />
@endif
@if ($application->build_pack !== 'dockercompose')
<div class="pt-2 w-96">
<x-forms.checkbox
helper="Use a build server to build your application. You can configure your build server in the Server settings. For more info, check the <a href='https://coolify.io/docs/knowledge-base/server/build-server' class='underline' target='_blank'>documentation</a>."
instantSave id="isBuildServerEnabled" label="Use a Build Server?"
x-bind:disabled="!canUpdate" />
</div>
@if ($buildPack === 'dockerfile')
<x-forms.input id="dockerfileTargetBuild" label="Docker Build Stage Target"
helper="Useful if you have multi-staged dockerfile."
x-bind:disabled="!canUpdate" />
@endif
@if ($application->could_set_build_commands())
@if ($application->settings->is_static)
<x-forms.input placeholder="/dist" id="publishDirectory"
label="Publish Directory" required x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="/" id="publishDirectory"
label="Publish Directory" x-bind:disabled="!canUpdate" />
@endif
@endif
</div>
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
<div class="pb-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
placeholder="src/pages/**" id="watchPaths" label="Watch Paths"
x-bind:disabled="!canUpdate" />
</div>
@endif
</div>
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k --hostname=myapp"
id="customDockerRunOptions" label="Custom Docker Options"
x-bind:disabled="!canUpdate" />
@if ($buildPack !== 'dockercompose')
<div class="pt-2 w-96">
<x-forms.checkbox
helper="Use a build server to build your application. You can configure your build server in the Server settings. For more info, check the <a href='https://coolify.io/docs/knowledge-base/server/build-server' class='underline' target='_blank'>documentation</a>."
instantSave id="isBuildServerEnabled" label="Use a Build Server?"
x-bind:disabled="!canUpdate" />
</div>
@endif
@endif
</div>
@endif
</div>
@if ($application->build_pack === 'dockercompose')
<div x-data="{ showRaw: true }">
<div class="flex items-center gap-2">
<h3>Docker Compose</h3>
<x-forms.button x-show="!($application->settings->is_raw_compose_deployment_enabled)" @click.prevent="showRaw = !showRaw" x-text="showRaw ? 'Show Deployable Compose' : 'Show Raw Compose'"></x-forms.button>
</div>
</div>
@if ($buildPack === 'dockercompose')
<div x-data="{ showRaw: true }">
<div class="flex items-center gap-2">
<h3>Docker Compose</h3>
<x-forms.button x-show="!($application->settings->is_raw_compose_deployment_enabled)"
@click.prevent="showRaw = !showRaw"
x-text="showRaw ? 'Show Deployable Compose' : 'Show Raw Compose'"></x-forms.button>
</div>
@if ($application->settings->is_raw_compose_deployment_enabled)
<x-forms.textarea rows="10" readonly id="dockerComposeRaw"
label="Docker Compose Content (applicationId: {{ $application->id }})"
@ -391,13 +401,15 @@
@else
@if ((int) $application->compose_parsing_version >= 3)
<div x-show="showRaw">
<x-forms.textarea rows="10" readonly id="dockerComposeRaw" label="Docker Compose Content (raw)"
<x-forms.textarea rows="10" readonly id="dockerComposeRaw"
label="Docker Compose Content (raw)"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
</div>
@endif
<div x-show="showRaw === false">
<x-forms.textarea rows="10" readonly id="dockerCompose" label="Docker Compose Content"
<x-forms.textarea rows="10" readonly id="dockerCompose"
label="Docker Compose Content"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
</div>
@ -405,172 +417,179 @@
<div class="w-96">
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="isContainerLabelEscapeEnabled" instantSave x-bind:disabled="!canUpdate"></x-forms.checkbox>
id="isContainerLabelEscapeEnabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
{{-- <x-forms.checkbox label="Readonly labels"
helper="Labels are readonly by default. Readonly means that edits you do to the labels could be lost and Coolify will autogenerate the labels for you. If you want to edit the labels directly, disable this option. <br><br>Be careful, it could break the proxy configuration after you restart the container as Coolify will now NOT autogenerate the labels for you (ofc you can always reset the labels to the coolify defaults manually)."
id="isContainerLabelReadonlyEnabled" instantSave></x-forms.checkbox> --}}
</div>
</div>
@endif
@if ($application->dockerfile)
<x-forms.textarea label="Dockerfile" id="dockerfile" monacoEditorLanguage="dockerfile" useMonacoEditor
rows="6" x-bind:disabled="!canUpdate"> </x-forms.textarea>
@endif
@if ($application->build_pack !== 'dockercompose')
<h3 class="pt-8">Network</h3>
@if ($this->detectedPortInfo)
@if ($this->detectedPortInfo['isEmpty'])
<div
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"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT environment variable detected
({{ $this->detectedPortInfo['port'] }})</span>
<p class="mt-1">Your Ports Exposes field is empty. Consider setting it to
<strong>{{ $this->detectedPortInfo['port'] }}</strong> to ensure the proxy routes traffic
correctly.</p>
</div>
</div>
@endif
@if ($application->dockerfile)
<x-forms.textarea label="Dockerfile" id="dockerfile" monacoEditorLanguage="dockerfile"
useMonacoEditor rows="6" x-bind:disabled="!canUpdate"> </x-forms.textarea>
@endif
@if ($buildPack !== 'dockercompose')
<h3 class="pt-8">Network</h3>
@if ($this->detectedPortInfo)
@if ($this->detectedPortInfo['isEmpty'])
<div
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"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT environment variable detected
({{ $this->detectedPortInfo['port'] }})</span>
<p class="mt-1">Your Ports Exposes field is empty. Consider setting it to
<strong>{{ $this->detectedPortInfo['port'] }}</strong> to ensure the proxy routes
traffic
correctly.
</p>
</div>
@elseif (!$this->detectedPortInfo['matches'])
<div
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"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT mismatch detected</span>
<p class="mt-1">Your PORT environment variable is set to
<strong>{{ $this->detectedPortInfo['port'] }}</strong>, but it's not in your Ports Exposes
configuration. Ensure they match for proper proxy routing.</p>
</div>
</div>
@elseif (!$this->detectedPortInfo['matches'])
<div
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"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT mismatch detected</span>
<p class="mt-1">Your PORT environment variable is set to
<strong>{{ $this->detectedPortInfo['port'] }}</strong>, but it's not in your Ports
Exposes
configuration. Ensure they match for proper proxy routing.
</p>
</div>
@else
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT environment variable configured</span>
<p class="mt-1">Your PORT environment variable ({{ $this->detectedPortInfo['port'] }}) matches
your Ports Exposes configuration.</p>
</div>
</div>
@else
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT environment variable configured</span>
<p class="mt-1">Your PORT environment variable
({{ $this->detectedPortInfo['port'] }}) matches
your Ports Exposes configuration.</p>
</div>
@endif
</div>
@endif
<div class="flex flex-col gap-2 xl:flex-row">
@if ($application->settings->is_static || $application->build_pack === 'static')
<x-forms.input id="portsExposes" label="Ports Exposes" readonly x-bind:disabled="!canUpdate" />
@endif
<div class="flex flex-col gap-2 xl:flex-row">
@if ($isStatic || $buildPack === 'static')
<x-forms.input id="portsExposes" label="Ports Exposes" readonly
x-bind:disabled="!canUpdate" />
@else
@if ($application->settings->is_container_label_readonly_enabled === false)
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes" readonly
helper="Readonly labels are disabled. You can set the ports manually in the labels section."
x-bind:disabled="!canUpdate" />
@else
@if ($application->settings->is_container_label_readonly_enabled === false)
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes" readonly
helper="Readonly labels are disabled. You can set the ports manually in the labels section."
x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes" required
helper="A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly."
x-bind:disabled="!canUpdate" />
@endif
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input placeholder="3000:3000" id="portsMappings" label="Ports Mappings"
helper="A comma separated list of ports you would like to map to the host system. Useful when you do not want to use domains.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>3000:3000,3002:3002<br><br>Rolling update is not supported if you have a port mapped to the host."
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes" required
helper="A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly."
x-bind:disabled="!canUpdate" />
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input id="customNetworkAliases" label="Network Aliases"
helper="A comma separated list of custom network aliases you would like to add for container in Docker network.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>api.internal,api.local"
wire:model="customNetworkAliases" x-bind:disabled="!canUpdate" />
@endif
</div>
<h3 class="pt-8">HTTP Basic Authentication</h3>
<div>
<div class="w-96">
<x-forms.checkbox helper="This will add the proper proxy labels to the container." instantSave
label="Enable" id="isHttpBasicAuthEnabled" x-bind:disabled="!canUpdate" />
</div>
@if ($application->is_http_basic_auth_enabled)
<div class="flex gap-2 py-2">
<x-forms.input id="httpBasicAuthUsername" label="Username" required
x-bind:disabled="!canUpdate" />
<x-forms.input id="httpBasicAuthPassword" type="password" label="Password" required
x-bind:disabled="!canUpdate" />
</div>
@endif
</div>
@if ($application->settings->is_container_label_readonly_enabled)
<x-forms.textarea readonly disabled label="Container Labels" rows="15" id="customLabels"
monacoEditorLanguage="ini" useMonacoEditor x-bind:disabled="!canUpdate"></x-forms.textarea>
@else
<x-forms.textarea label="Container Labels" rows="15" id="customLabels" monacoEditorLanguage="ini"
useMonacoEditor x-bind:disabled="!canUpdate"></x-forms.textarea>
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input placeholder="3000:3000" id="portsMappings" label="Ports Mappings"
helper="A comma separated list of ports you would like to map to the host system. Useful when you do not want to use domains.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>3000:3000,3002:3002<br><br>Rolling update is not supported if you have a port mapped to the host."
x-bind:disabled="!canUpdate" />
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input id="customNetworkAliases" label="Network Aliases"
helper="A comma separated list of custom network aliases you would like to add for container in Docker network.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>api.internal,api.local"
wire:model="customNetworkAliases" x-bind:disabled="!canUpdate" />
@endif
</div>
<h3 class="pt-8">HTTP Basic Authentication</h3>
<div>
<div class="w-96">
<x-forms.checkbox label="Readonly labels"
helper="Labels are readonly by default. Readonly means that edits you do to the labels could be lost and Coolify will autogenerate the labels for you. If you want to edit the labels directly, disable this option. <br><br>Be careful, it could break the proxy configuration after you restart the container as Coolify will now NOT autogenerate the labels for you (ofc you can always reset the labels to the coolify defaults manually)."
id="isContainerLabelReadonlyEnabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="isContainerLabelEscapeEnabled" instantSave x-bind:disabled="!canUpdate"></x-forms.checkbox>
<x-forms.checkbox helper="This will add the proper proxy labels to the container." instantSave
label="Enable" id="isHttpBasicAuthEnabled" x-bind:disabled="!canUpdate" />
</div>
@can('update', $application)
<x-modal-confirmation title="Confirm Labels Reset to Coolify Defaults?"
buttonTitle="Reset Labels to Defaults" buttonFullWidth submitAction="resetDefaultLabels(true)"
:actions="[
@if ($application->is_http_basic_auth_enabled)
<div class="flex gap-2 py-2">
<x-forms.input id="httpBasicAuthUsername" label="Username" required
x-bind:disabled="!canUpdate" />
<x-forms.input id="httpBasicAuthPassword" type="password" label="Password" required
x-bind:disabled="!canUpdate" />
</div>
@endif
</div>
@if ($application->settings->is_container_label_readonly_enabled)
<x-forms.textarea readonly disabled label="Container Labels" rows="15" id="customLabels"
monacoEditorLanguage="ini" useMonacoEditor x-bind:disabled="!canUpdate"></x-forms.textarea>
@else
<x-forms.textarea label="Container Labels" rows="15" id="customLabels"
monacoEditorLanguage="ini" useMonacoEditor x-bind:disabled="!canUpdate"></x-forms.textarea>
@endif
<div class="w-96">
<x-forms.checkbox label="Readonly labels"
helper="Labels are readonly by default. Readonly means that edits you do to the labels could be lost and Coolify will autogenerate the labels for you. If you want to edit the labels directly, disable this option. <br><br>Be careful, it could break the proxy configuration after you restart the container as Coolify will now NOT autogenerate the labels for you (ofc you can always reset the labels to the coolify defaults manually)."
id="isContainerLabelReadonlyEnabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="isContainerLabelEscapeEnabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
</div>
@can('update', $application)
<x-modal-confirmation title="Confirm Labels Reset to Coolify Defaults?"
buttonTitle="Reset Labels to Defaults" buttonFullWidth submitAction="resetDefaultLabels(true)"
:actions="[
'All your custom proxy labels will be lost.',
'Proxy labels (traefik, caddy, etc) will be reset to the coolify defaults.',
]" confirmationText="{{ $application->fqdn . '/' }}"
confirmationLabel="Please confirm the execution of the actions by entering the Application URL below"
shortConfirmationLabel="Application URL" :confirmWithPassword="false"
step2ButtonText="Permanently Reset Labels" />
@endcan
@endif
confirmationLabel="Please confirm the execution of the actions by entering the Application URL below"
shortConfirmationLabel="Application URL" :confirmWithPassword="false"
step2ButtonText="Permanently Reset Labels" />
@endcan
@endif
<h3 class="pt-8">Pre/Post Deployment Commands</h3>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="preDeploymentCommand" label="Pre-deployment "
helper="An optional script or command to execute in the existing container before the deployment begins.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($application->build_pack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()" id="preDeploymentCommandContainer"
label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="postDeploymentCommand" label="Post-deployment "
helper="An optional script or command to execute in the newly built container after the deployment completes.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($application->build_pack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()" id="postDeploymentCommandContainer"
label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>
<h3 class="pt-8">Pre/Post Deployment Commands</h3>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="preDeploymentCommand" label="Pre-deployment "
helper="An optional script or command to execute in the existing container before the deployment begins.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($buildPack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()" id="preDeploymentCommandContainer"
label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="postDeploymentCommand" label="Post-deployment "
helper="An optional script or command to execute in the newly built container after the deployment completes.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($buildPack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()" id="postDeploymentCommandContainer"
label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>
</div>
</form>
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage" />
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal" confirmAction="confirmDomainUsage" />
@script
<script>
$wire.$on('loadCompose', (isInit = true) => {
// Only load compose file if user has permission (this event should only be dispatched when authorized)
$wire.initLoadingCompose = true;
$wire.loadComposeFile(isInit);
});
</script>
<script>
$wire.$on('loadCompose', (isInit = true) => {
// Only load compose file if user has permission (this event should only be dispatched when authorized)
$wire.initLoadingCompose = true;
$wire.loadComposeFile(isInit);
});
</script>
@endscript
</div>
</div>

View file

@ -7,8 +7,8 @@
@if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose')
<div class="alert alert-warning">Metrics are not available for Docker Compose applications yet!</div>
@elseif(!$resource->destination->server->isMetricsEnabled())
<div class="alert alert-warning">Metrics are only available for servers with Sentinel & Metrics enabled!</div>
<div>Go to <a class="underline dark:text-white" href="{{ route('server.show', $resource->destination->server->uuid) }}" {{ wireNavigate() }}>Server settings</a> to enable it.</div>
<div class="alert alert-warning pb-1">Metrics are only available for servers with Sentinel & Metrics enabled!</div>
<div>Go to <a class="underline dark:text-white" href="{{ route('server.show', $resource->destination->server->uuid) }}/sentinel" {{ wireNavigate() }}>Server settings</a> to enable it.</div>
@else
@if (!str($resource->status)->contains('running'))
<div class="alert alert-warning">Metrics are only available when the application container is running!</div>

View file

@ -289,7 +289,7 @@
</div>
@else
<div>Metrics are disabled for this server. Enable them in <a class="underline dark:text-white"
href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}>General</a> settings.</div>
href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}/sentinel" {{ wireNavigate() }}>Sentinel</a> settings.</div>
@endif
</div>
</div>

View file

@ -81,7 +81,7 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
<div class="p-4 rounded-lg bg-neutral-200 dark:bg-coolgray-200">
<div class="flex items-center gap-3">
<template x-if="!upgradeComplete && !upgradeError">
<svg class="w-5 h-5 text-warning animate-spin"
<svg class="w-5 h-5 text-black dark:text-warning animate-spin"
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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,131 @@
<?php
use App\Models\Server;
/**
* Helper to call the private downsampleLTTB method on Server model via reflection.
*/
function callDownsampleLTTB(array $data, int $threshold): array
{
$server = new Server;
$reflection = new ReflectionClass($server);
$method = $reflection->getMethod('downsampleLTTB');
return $method->invoke($server, $data, $threshold);
}
it('returns data unchanged when below threshold', function () {
$data = [
[1000, 10.5],
[2000, 20.3],
[3000, 15.7],
];
$result = callDownsampleLTTB($data, 1000);
expect($result)->toBe($data);
});
it('returns data unchanged when threshold is 2 or less', function () {
$data = [
[1000, 10.5],
[2000, 20.3],
[3000, 15.7],
[4000, 25.0],
[5000, 12.0],
];
$result = callDownsampleLTTB($data, 2);
expect($result)->toBe($data);
$result = callDownsampleLTTB($data, 1);
expect($result)->toBe($data);
});
it('downsamples to target threshold count', function () {
// Seed for reproducibility
mt_srand(42);
// Generate 100 data points
$data = [];
for ($i = 0; $i < 100; $i++) {
$data[] = [$i * 1000, mt_rand(0, 100) / 10];
}
$result = callDownsampleLTTB($data, 10);
expect(count($result))->toBe(10);
});
it('preserves first and last data points', function () {
$data = [];
for ($i = 0; $i < 100; $i++) {
$data[] = [$i * 1000, $i * 1.5];
}
$result = callDownsampleLTTB($data, 20);
// First point should be preserved
expect($result[0])->toBe($data[0]);
// Last point should be preserved
expect(end($result))->toBe(end($data));
});
it('maintains chronological order', function () {
$data = [];
for ($i = 0; $i < 500; $i++) {
$data[] = [$i * 60000, sin($i / 10) * 50 + 50]; // Sine wave pattern
}
$result = callDownsampleLTTB($data, 50);
// Verify all timestamps are in non-decreasing order
$previousTimestamp = -1;
foreach ($result as $point) {
expect($point[0])->toBeGreaterThanOrEqual($previousTimestamp);
$previousTimestamp = $point[0];
}
});
it('handles large datasets efficiently', function () {
// Seed for reproducibility
mt_srand(123);
// Simulate 30 days of data at 5-second intervals (518,400 points)
// For test purposes, use 10,000 points
$data = [];
for ($i = 0; $i < 10000; $i++) {
$data[] = [$i * 5000, mt_rand(0, 100)];
}
$startTime = microtime(true);
$result = callDownsampleLTTB($data, 1000);
$executionTime = microtime(true) - $startTime;
expect(count($result))->toBe(1000);
expect($executionTime)->toBeLessThan(1.0); // Should complete in under 1 second
});
it('preserves peaks and valleys in data', function () {
// Create data with clear peaks and valleys
$data = [];
for ($i = 0; $i < 100; $i++) {
if ($i === 25) {
$value = 100; // Peak
} elseif ($i === 75) {
$value = 0; // Valley
} else {
$value = 50;
}
$data[] = [$i * 1000, $value];
}
$result = callDownsampleLTTB($data, 20);
// The peak (100) and valley (0) should be preserved due to LTTB algorithm
$values = array_column($result, 1);
expect(in_array(100, $values))->toBeTrue();
expect(in_array(0, $values))->toBeTrue();
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.459"
"version": "4.0.0-beta.460"
},
"nightly": {
"version": "4.0.0-beta.460"
"version": "4.0.0-beta.461"
},
"helper": {
"version": "1.0.12"