feat(ui): Shadow UI redesign, domain management, and DNS autoconfigure (#11119)

Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
This commit is contained in:
Andras BacsaiandShadowArcanist authored and GitHub committed 2026-08-03 23:11:54 +02:00
1 parent 0b843bb07c
commit 8ae56587d5
497 files changed
+44045 -22630

No files matched your search

+4 -1
View File
@@ -24,6 +24,9 @@ class CleanupDocker
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
$buildxMetadataVolume = isDev() && $server->isLocalhost()
? 'coolify-buildx'
: '$HOME/.docker/buildx';
$cleanupLog = [];
@@ -51,7 +54,7 @@ class CleanupDocker
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
"docker run --rm -v \$HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
"docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
+5 -4
View File
@@ -18,7 +18,6 @@ use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
class Init extends Command
@@ -161,10 +160,12 @@ class Init extends Command
private function pullTemplatesFromCDN()
{
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
$response = Http::retry(3, 1000, throw: false)
->timeout(60)
->connectTimeout(10)
->get(config('constants.services.official'));
if ($response->successful()) {
$services = $response->json();
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
store_service_templates_bundle($response->body());
}
}
@@ -362,6 +362,7 @@ class ApplicationsController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
],
),
],
@@ -554,6 +555,7 @@ class ApplicationsController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
],
),
],
@@ -746,6 +748,7 @@ class ApplicationsController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
],
),
],
@@ -1332,9 +1335,10 @@ class ApplicationsController extends Controller
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
];
// ports_exposes is not required for dockercompose
if ($request->build_pack === 'dockercompose') {
@@ -1343,7 +1347,7 @@ class ApplicationsController extends Controller
}
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
if ($validator->fails()) {
@@ -1435,7 +1439,12 @@ class ApplicationsController extends Controller
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
$entry = ['domain' => data_get($domain, 'domain')];
$redirect = data_get($domain, 'redirect');
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry);
});
$request->offsetUnset('docker_compose_domains');
}
@@ -1552,13 +1561,14 @@ class ApplicationsController extends Controller
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
if ($validator->fails()) {
@@ -1688,7 +1698,12 @@ class ApplicationsController extends Controller
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
$entry = ['domain' => data_get($domain, 'domain')];
$redirect = data_get($domain, 'redirect');
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry);
});
$request->offsetUnset('docker_compose_domains');
}
@@ -1804,14 +1819,15 @@ class ApplicationsController extends Controller
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
@@ -1913,7 +1929,12 @@ class ApplicationsController extends Controller
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
$entry = ['domain' => data_get($domain, 'domain')];
$redirect = data_get($domain, 'redirect');
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry);
});
$request->offsetUnset('docker_compose_domains');
}
@@ -2647,6 +2668,7 @@ class ApplicationsController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
],
),
],
@@ -2773,9 +2795,10 @@ class ApplicationsController extends Controller
'static_image' => 'string',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'is_preview_deployments_enabled' => 'boolean|nullable',
@@ -2785,7 +2808,7 @@ class ApplicationsController extends Controller
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
@@ -2993,10 +3016,18 @@ class ApplicationsController extends Controller
$yaml = Yaml::parse($application->docker_compose_raw);
$services = data_get($yaml, 'services', []);
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson) {
$existingDockerComposeDomains = json_decode($application->docker_compose_domains ?? '[]', true) ?? [];
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson, $existingDockerComposeDomains) {
$name = data_get($domain, 'name');
if ($name && is_array($services) && isset($services[$name])) {
$dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]);
$entry = ['domain' => data_get($domain, 'domain')];
$redirect = array_key_exists('redirect', $domain)
? data_get($domain, 'redirect')
: data_get($existingDockerComposeDomains[$name] ?? [], 'redirect');
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomainsJson->put($name, $entry);
}
});
$request->offsetUnset('docker_compose_domains');
@@ -858,7 +858,7 @@ class ServersController extends Controller
if ($server->definedResources()->count() > 0 && ! $force) {
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
}
if ($server->isLocalhost()) {
if ($server->is_coolify_host) {
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
+3
View File
@@ -2,6 +2,7 @@
namespace App\Http;
use App\Http\Middleware\AddServerTimingHeaders;
use App\Http\Middleware\ApiAbility;
use App\Http\Middleware\ApiSensitiveData;
use App\Http\Middleware\Authenticate;
@@ -51,6 +52,8 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
// Outermost so Server-Timing includes the full middleware + app cost.
AddServerTimingHeaders::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
@@ -0,0 +1,152 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Symfony\Component\HttpFoundation\Response;
/**
* Adds W3C Server-Timing headers in local/dev so Chrome DevTools can show
* app + database cost per response (Network → Timing → Server Timing).
*
* Also injects a small on-screen HUD into full HTML documents so metrics are
* visible without opening DevTools. Livewire/fetch responses only get headers;
* the HUD updates from Server-Timing on those requests via a fetch patch.
*
* Client-side metrics (paint, LCP, layout, JS/CSS download) cannot be measured
* here — use the Performance panel. Compare app dur vs wall-clock TTFB to see
* network/proxy overhead outside PHP.
*/
class AddServerTimingHeaders
{
public function handle(Request $request, Closure $next): Response
{
if (! $this->shouldAddHeaders()) {
return $next($request);
}
$startedAt = hrtime(true);
$queryCount = 0;
$queryTimeMs = 0.0;
$slowestQueryMs = 0.0;
$active = true;
Event::listen(QueryExecuted::class, function (QueryExecuted $query) use (&$queryCount, &$queryTimeMs, &$slowestQueryMs, &$active): void {
if (! $active) {
return;
}
$queryCount++;
$queryTimeMs += $query->time;
if ($query->time > $slowestQueryMs) {
$slowestQueryMs = $query->time;
}
});
$response = $next($request);
$active = false;
return $this->withServerTiming($response, $request, $startedAt, $queryCount, $queryTimeMs, $slowestQueryMs);
}
protected function shouldAddHeaders(): bool
{
return (bool) config('app.server_timing', false);
}
protected function withServerTiming(
Response $response,
Request $request,
int $startedAt,
int $queryCount,
float $queryTimeMs,
float $slowestQueryMs,
): Response {
$totalMs = (hrtime(true) - $startedAt) / 1_000_000;
$memoryMb = round(memory_get_peak_usage(true) / 1024 / 1024, 2);
$content = $response->getContent();
$htmlBytes = is_string($content) ? strlen($content) : 0;
$metrics = [
'app' => round($totalMs, 2),
'db' => round($queryTimeMs, 2),
'php' => round(max(0, $totalMs - $queryTimeMs), 2),
'dbslow' => round($slowestQueryMs, 2),
'queries' => $queryCount,
'html' => $htmlBytes,
'mem' => $memoryMb,
];
// Non-time metrics use dur so Chrome DevTools lists the value in Server Timing.
// queries = count, html = response body bytes (not milliseconds).
$headerMetrics = [
sprintf('app;desc="Total";dur=%.2f', $metrics['app']),
sprintf('db;desc="Database (%d queries)";dur=%.2f', $queryCount, $metrics['db']),
sprintf('php;desc="PHP (excl. DB)";dur=%.2f', $metrics['php']),
sprintf('dbslow;desc="Slowest query";dur=%.2f', $metrics['dbslow']),
sprintf('queries;desc="Query count";dur=%d', $queryCount),
sprintf('html;desc="Response bytes";dur=%d', $htmlBytes),
sprintf('mem;desc="Peak memory (MB)";dur=%.2f', $memoryMb),
];
$response->headers->set('Server-Timing', implode(', ', $headerMetrics));
// Convenience mirrors for curl / non-DevTools clients.
$response->headers->set('X-Debug-Memory-MB', (string) $memoryMb);
$response->headers->set('X-Debug-Query-Count', (string) $queryCount);
$response->headers->set('X-Debug-Html-Bytes', (string) $htmlBytes);
return $this->injectHud($response, $request, $metrics);
}
/**
* Inject a floating HUD into full HTML documents only (not Livewire partials/JSON).
*/
protected function injectHud(Response $response, Request $request, array $metrics): Response
{
$content = $response->getContent();
if (! is_string($content) || $content === '') {
return $response;
}
if (! $this->isFullHtmlDocument($response, $content)) {
return $response;
}
// Avoid double-inject (e.g. nested error pages).
if (str_contains($content, 'id="server-timing-hud"') || str_contains($content, "id='server-timing-hud'")) {
return $response;
}
$hud = view('components.server-timing-hud', [
'metrics' => $metrics,
'path' => '/'.ltrim($request->path(), '/'),
])->render();
$replaced = preg_replace('/<\/body>/i', $hud.'</body>', $content, 1, $count);
if ($count === 0 || ! is_string($replaced)) {
return $response;
}
$response->setContent($replaced);
$response->headers->remove('Content-Length');
// Keep html metric as pre-HUD page size (more useful for profiling the app).
return $response;
}
protected function isFullHtmlDocument(Response $response, string $content): bool
{
$contentType = (string) $response->headers->get('Content-Type', '');
if ($contentType !== '' && ! str_contains(strtolower($contentType), 'text/html')) {
return false;
}
// Full documents only — skip Livewire component HTML fragments.
return str_contains(strtolower($content), '</body>')
&& (str_contains(strtolower($content), '<html') || str_contains(strtolower($content), '<!doctype'));
}
}
+3 -1
View File
@@ -2160,7 +2160,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
$env_flags = $this->generate_docker_env_flags_for_secrets();
$buildxMetadataVolume = "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
$buildxMetadataVolume = isDev() && $this->server->isLocalhost()
? '-v coolify-buildx:/root/.docker/buildx'
: "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') {
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
+15 -5
View File
@@ -8,14 +8,14 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 10;
public $timeout = 60;
public function __construct()
{
@@ -28,14 +28,24 @@ class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue
if (isDev()) {
return;
}
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
$response = Http::retry(3, 1000, throw: false)
->timeout(60)
->connectTimeout(10)
->get(config('constants.services.official'));
if ($response->successful()) {
$services = $response->json();
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
// Shared cache so Cloud HTTP nodes see the same bundle Horizon pulled.
store_service_templates_bundle($response->body());
} else {
Log::error('PullTemplatesFromCDN failed', [
'status' => $response->status(),
'body' => str($response->body())->limit(500)->toString(),
]);
send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body());
}
} catch (\Throwable $e) {
Log::error('PullTemplatesFromCDN exception', [
'message' => $e->getMessage(),
]);
send_internal_notification('PullTemplatesAndVersions failed with: '.$e->getMessage());
}
}
@@ -0,0 +1,257 @@
<?php
namespace App\Livewire\Concerns;
use App\Support\DnsRecordHints;
use App\Support\DomainConnect\CloudflareDomainConnect;
use Illuminate\Support\Js;
trait InteractsWithCloudflareDomainConnect
{
public bool $showCloudflareAutoconfigureModal = false;
public bool $showDnsRecordsModal = false;
public function domainConnectAvailable(): bool
{
return app(CloudflareDomainConnect::class)->isAvailable();
}
public function openCloudflareAutoconfigureModal(): void
{
$this->authorizeUpdateForDomainConnect();
if (! $this->domainConnectAvailable()) {
$this->dispatch('error', 'Automated DNS configuration is only available on Coolify Cloud when Domain Connect is configured.');
return;
}
if ($this->allDomainHostnames() === []) {
$this->dispatch('error', 'Add at least one domain before configuring DNS on Cloudflare.');
return;
}
$this->showCloudflareAutoconfigureModal = true;
}
public function closeCloudflareAutoconfigureModal(): void
{
$this->showCloudflareAutoconfigureModal = false;
}
public function applyCloudflareAutoconfigure(): void
{
$this->authorizeUpdateForDomainConnect();
$domainConnect = app(CloudflareDomainConnect::class);
if (! $domainConnect->isAvailable()) {
$this->dispatch(
'error',
'Automated DNS configuration is only available on Coolify Cloud when a Domain Connect private key is configured.'
);
return;
}
$ip = $this->serverIpForDomainConnect();
if (blank($ip) || filter_var($ip, FILTER_VALIDATE_IP) === false) {
$this->dispatch(
'error',
'A resolvable server IP is required before autoconfiguring DNS. Set a public IP on the server (or instance settings for localhost).'
);
return;
}
$hostnames = $this->allDomainHostnames();
if ($hostnames === []) {
$this->dispatch('error', 'Add at least one domain before configuring DNS on Cloudflare.');
return;
}
$urls = [];
try {
foreach ($hostnames as $hostname) {
$parts = CloudflareDomainConnect::splitHostname($hostname);
$urls[] = $domainConnect->buildHostingApplyUrl(
domain: $parts['domain'],
ip: $ip,
host: $parts['host'] !== '' ? $parts['host'] : null,
);
}
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
return;
}
$urls = array_values(array_unique($urls));
$this->showCloudflareAutoconfigureModal = false;
$openScript = collect($urls)
->map(fn (string $url) => 'window.open('.Js::from($url).', "_blank", "noopener,noreferrer");')
->implode("\n");
$this->js($openScript);
}
public function openDnsRecordsModal(): void
{
$this->authorizeUpdateForDomainConnect();
$this->showDnsRecordsModal = true;
}
public function closeDnsRecordsModal(): void
{
$this->showDnsRecordsModal = false;
}
public function recheckDnsRecordsInModal(): void
{
$this->authorizeUpdateForDomainConnect();
$this->checkAllDns();
}
/**
* DNS records for hosts that still need attention (not already resolving correctly).
*
* @return array<int, array{type: string, name: string, value: string}>
*/
public function dnsRecordHints(): array
{
[$ipv4, $ipv6] = $this->serverIpsForDnsHints();
return DnsRecordHints::forHostnames($this->allDomainHostnames(onlyNeedingDns: true), $ipv4, $ipv6);
}
public function dnsRecordsCopyText(): string
{
return DnsRecordHints::toCopyText($this->dnsRecordHints());
}
/**
* Unique hostnames from domain rows (and optional form inputs).
*
* @param bool $onlyNeedingDns When true, skip hosts whose DNS status is already ok.
* @return array<int, string>
*/
protected function allDomainHostnames(bool $onlyNeedingDns = false): array
{
$hosts = [];
$workingHosts = [];
foreach ($this->domainRows ?? [] as $row) {
$url = $row['url'] ?? null;
if (! is_string($url) || $url === '') {
continue;
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
continue;
}
$host = strtolower($host);
// Working configured domains: DNS already points at this server.
if (($row['dns_status'] ?? null) === 'ok' && ! ($row['is_suggested'] ?? false)) {
$workingHosts[$host] = true;
}
$hosts[] = $host;
}
if (filled($this->newDomain ?? null)) {
$candidate = $this->normalizeHostnameInput((string) $this->newDomain);
if ($candidate !== '') {
$hosts[] = strtolower($candidate);
}
}
if (filled($this->editingDomain ?? null) && ($this->showEditDomainModal ?? false)) {
$candidate = $this->normalizeHostnameInput((string) $this->editingDomain);
if ($candidate !== '') {
$hosts[] = strtolower($candidate);
}
}
$hosts = array_values(array_unique(array_filter($hosts)));
if ($onlyNeedingDns) {
$hosts = array_values(array_filter(
$hosts,
fn (string $host) => ! isset($workingHosts[$host])
));
}
sort($hosts);
return $hosts;
}
/**
* @return array{0: ?string, 1: ?string}
*/
protected function serverIpsForDnsHints(): array
{
$ipv4 = null;
$ipv6 = null;
$ip = $this->serverIpForDomainConnect();
if (filled($ip)) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$ipv4 = $ip;
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$ipv6 = $ip;
}
}
// Prefer instance public IPv6 when the destination IP is IPv4-only (and vice versa).
try {
$settings = instanceSettings();
$publicV4 = data_get($settings, 'public_ipv4');
$publicV6 = data_get($settings, 'public_ipv6');
if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ipv4 = $publicV4;
}
if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ipv6 = $publicV6;
}
} catch (\Throwable) {
//
}
return [$ipv4, $ipv6];
}
protected function defaultDnsHostname(): string
{
$hosts = $this->allDomainHostnames();
return $hosts[0] ?? '';
}
protected function normalizeHostnameInput(string $hostname): string
{
$hostname = trim($hostname);
$hostname = preg_replace('#^https?://#i', '', $hostname) ?? $hostname;
$hostname = explode('/', $hostname)[0] ?? $hostname;
$hostname = explode(':', $hostname)[0] ?? $hostname;
return rtrim($hostname, '.');
}
protected function serverIpForDomainConnect(): ?string
{
if (filled($this->serverIp) && filter_var($this->serverIp, FILTER_VALIDATE_IP) !== false) {
return $this->serverIp;
}
return null;
}
abstract protected function authorizeUpdateForDomainConnect(): void;
}
+15 -1
View File
@@ -20,7 +20,21 @@ class Dashboard extends Component
{
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
$this->servers = Server::ownedByCurrentTeamCached();
$this->projects = Project::ownedByCurrentTeam()->with('environments')->get();
$this->projects = Project::ownedByCurrentTeam()
->with(['environments:id,uuid,name,project_id'])
->withCount([
'applications',
'services',
'postgresqls',
'redis',
'keydbs',
'dragonflies',
'clickhouses',
'mongodbs',
'mysqls',
'mariadbs',
])
->get();
}
public function render()
@@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Dashboard;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Support\Collection;
use Livewire\Component;
class ActiveDeployments extends Component
{
public Collection $activeDeployments;
public Collection $recentDeployments;
public function mount(): void
{
$this->refreshDeployments();
}
public function refreshDeployments(): void
{
$serverIds = Server::ownedByCurrentTeamCached()->pluck('id');
$columns = [
'id',
'application_id',
'application_name',
'deployment_url',
'deployment_uuid',
'pull_request_id',
'server_name',
'server_id',
'status',
'created_at',
'finished_at',
];
$baseQuery = ApplicationDeploymentQueue::query()
->with(['application.environment.project'])
->whereIn('server_id', $serverIds);
$this->activeDeployments = (clone $baseQuery)
->whereIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
->orderBy('status')
->orderBy('id')
->limit(5)
->get($columns);
$this->recentDeployments = (clone $baseQuery)
->whereNotIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
->orderByDesc('id')
->limit(5)
->get($columns);
}
public function render()
{
return view('livewire.dashboard.active-deployments');
}
}
+32
View File
@@ -11,6 +11,22 @@ class DeploymentsIndicator extends Component
{
public bool $expanded = false;
/**
* Persisted across polls. Livewire update requests are not the page route,
* so this must not be re-derived from request()->routeIs() on every render.
*/
public bool $shouldShow = true;
public function mount(): void
{
$this->shouldShow = $this->shouldShowForCurrentRequest();
}
public function updateShouldShowFromPath(string $path): void
{
$this->shouldShow = ! $this->isDashboardPath($path);
}
#[Computed]
public function deployments()
{
@@ -53,4 +69,20 @@ class DeploymentsIndicator extends Component
{
return view('livewire.deployments-indicator');
}
private function shouldShowForCurrentRequest(): bool
{
if (request()->routeIs('dashboard')) {
return false;
}
return ! $this->isDashboardPath(request()->path());
}
private function isDashboardPath(string $path): bool
{
$normalized = trim($path, '/');
return $normalized === '' || $normalized === '/';
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Notifications\Concerns;
trait TogglesNotificationEvents
{
private const NOTIFICATION_EVENT_KEYS = [
'deploymentSuccess',
'deploymentFailure',
'statusChange',
'backupSuccess',
'backupFailure',
'scheduledTaskSuccess',
'scheduledTaskFailure',
'dockerCleanupSuccess',
'dockerCleanupFailure',
'serverDiskUsage',
'serverReachable',
'serverUnreachable',
'serverPatch',
'traefikOutdated',
];
public function toggleEvent(string $property): void
{
$channel = class_basename(static::class);
$allowedProperties = array_map(
static fn (string $event): string => $event.$channel.'Notifications',
self::NOTIFICATION_EVENT_KEYS,
);
abort_unless(in_array($property, $allowedProperties, true), 404);
$this->{$property} = ! $this->{$property};
$this->saveModel();
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\DiscordNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@@ -12,7 +13,7 @@ use Livewire\Component;
class Discord extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
public Team $team;
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\EmailNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@@ -13,7 +14,7 @@ use Livewire\Component;
class Email extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
protected $listeners = ['refresh' => '$refresh'];
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\PushoverNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@@ -12,7 +13,7 @@ use Livewire\Component;
class Pushover extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
protected $listeners = ['refresh' => '$refresh'];
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\SlackNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@@ -13,7 +14,7 @@ use Livewire\Component;
class Slack extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
protected $listeners = ['refresh' => '$refresh'];
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\Team;
use App\Models\TelegramNotificationSettings;
use App\Notifications\Test;
@@ -12,7 +13,7 @@ use Livewire\Component;
class Telegram extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
protected $listeners = ['refresh' => '$refresh'];
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\Team;
use App\Models\WebhookNotificationSettings;
use App\Notifications\Test;
@@ -12,7 +13,7 @@ use Livewire\Component;
class Webhook extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
public Team $team;
@@ -2,7 +2,9 @@
namespace App\Livewire\Project\Application\Deployment;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -28,6 +30,18 @@ class Index extends Component
public ?string $pull_request_id = null;
public array $pullRequestOptions = [];
public string $search = '';
public string $deploymentFilter = 'all';
public string $deploymentSort = 'newest';
public array $statusFilterOptions = [];
public array $sourceFilterOptions = [];
protected $queryString = ['pull_request_id'];
public function getListeners()
@@ -64,8 +78,16 @@ class Index extends Component
}
}
['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->defaultTake, $this->pull_request_id);
$this->application = $application;
$this->loadPullRequestOptions();
$this->loadDeploymentFilterOptions();
['deployments' => $deployments, 'count' => $count] = $application->deployments(
search: $this->search,
filter: $this->deploymentFilter,
sort: $this->deploymentSort,
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
);
$this->deployments = $deployments;
$this->deployments_count = $count;
$this->current_url = url()->current();
@@ -114,14 +136,75 @@ class Index extends Component
$this->loadDeployments();
}
public function goToPage(int $page): void
{
$lastPage = max(1, (int) ceil($this->deployments_count / $this->defaultTake));
$page = max(1, min($page, $lastPage));
$this->skip = ($page - 1) * $this->defaultTake;
$this->showPrev = $page > 1;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function loadDeployments()
{
['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->defaultTake, $this->pull_request_id);
['deployments' => $deployments, 'count' => $count] = $this->application->deployments(
skip: $this->skip,
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
search: $this->search,
filter: $this->deploymentFilter,
sort: $this->deploymentSort,
);
$this->deployments = $deployments;
$this->deployments_count = $count;
$this->showMore();
}
public function updatedSearch(): void
{
$this->resetPaginationAndLoad();
}
public function setDeploymentFilter(string $filter): void
{
$validFilters = collect($this->statusFilterOptions)
->concat($this->sourceFilterOptions)
->pluck('value')
->push('all');
if (! $validFilters->contains($filter)) {
return;
}
$this->deploymentFilter = $filter;
$this->pull_request_id = null;
$this->resetPaginationAndLoad();
}
public function setPullRequestFilter(string $pullRequestId): void
{
$validPullRequestIds = collect($this->pullRequestOptions)->pluck('value');
if (! $validPullRequestIds->contains($pullRequestId)) {
return;
}
$this->pull_request_id = $pullRequestId === '' ? null : $pullRequestId;
$this->deploymentFilter = 'all';
$this->resetPaginationAndLoad();
}
public function setDeploymentSort(string $sort): void
{
if (! in_array($sort, ['newest', 'oldest'], true)) {
return;
}
$this->deploymentSort = $sort;
$this->resetPaginationAndLoad();
}
public function updatedPullRequestId($value)
{
// Sanitize and validate the pull request ID
@@ -139,20 +222,15 @@ class Index extends Component
$this->pull_request_id = null;
}
// Reset pagination when filter changes
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
$this->deploymentFilter = 'all';
$this->resetPaginationAndLoad();
}
public function clearFilter()
{
$this->pull_request_id = null;
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
$this->deploymentFilter = 'all';
$this->resetPaginationAndLoad();
}
private function updateCurrentPage()
@@ -160,6 +238,95 @@ class Index extends Component
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
}
private function loadPullRequestOptions(): void
{
$pullRequestIds = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->where('pull_request_id', '>', 0)
->distinct()
->orderByDesc('pull_request_id')
->pluck('pull_request_id')
->map(fn ($pullRequestId) => (string) $pullRequestId)
->values();
if ($this->pull_request_id && ! $pullRequestIds->contains($this->pull_request_id)) {
$this->pull_request_id = null;
}
$this->pullRequestOptions = collect([
['value' => '', 'label' => 'All deployments'],
])
->concat($pullRequestIds->map(fn ($pullRequestId) => [
'value' => $pullRequestId,
'label' => "Pull request #{$pullRequestId}",
]))
->all();
}
private function loadDeploymentFilterOptions(): void
{
$statuses = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->distinct()
->pluck('status');
$statusLabels = [
ApplicationDeploymentStatus::FINISHED->value => 'Success',
ApplicationDeploymentStatus::FAILED->value => 'Failed',
ApplicationDeploymentStatus::IN_PROGRESS->value => 'In progress',
ApplicationDeploymentStatus::QUEUED->value => 'Queued',
ApplicationDeploymentStatus::CANCELLED_BY_USER->value => 'Cancelled',
];
$this->statusFilterOptions = collect($statusLabels)
->filter(fn (string $label, string $status) => $statuses->contains($status))
->map(fn (string $label, string $status) => [
'value' => "status:{$status}",
'label' => $label,
])
->values()
->all();
$sourceKeys = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->selectRaw("
CASE
WHEN pull_request_id > 0 THEN 'pull-request'
WHEN is_webhook THEN 'webhook'
WHEN rollback THEN 'rollback'
WHEN is_api THEN 'api'
ELSE 'manual'
END AS source_key
")
->distinct()
->pluck('source_key');
$sourceLabels = [
'manual' => 'Manual',
'pull-request' => 'Pull requests',
'webhook' => 'Webhooks',
'api' => 'API',
'rollback' => 'Rollbacks',
];
$this->sourceFilterOptions = collect($sourceLabels)
->filter(fn (string $label, string $source) => $sourceKeys->contains($source))
->map(fn (string $label, string $source) => [
'value' => "source:{$source}",
'label' => $label,
])
->values()
->all();
}
private function resetPaginationAndLoad(): void
{
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function render()
{
return view('livewire.project.application.deployment.index');
File diff suppressed because it is too large. Load diff
+23 -3
View File
@@ -94,6 +94,9 @@ class General extends Component
public bool $isSpa = false;
/** UI-only aggregate of isStatic/isSpa: dynamic | static | spa */
public string $siteType = 'dynamic';
public bool $isBuildServerEnabled = false;
public bool $isPreserveRepositoryEnabled = false;
@@ -437,6 +440,7 @@ class General extends Component
// Application settings properties
$this->isStatic = $this->application->settings->is_static;
$this->isSpa = $this->application->settings->is_spa;
$this->siteType = $this->isStatic ? ($this->isSpa ? 'spa' : 'static') : 'dynamic';
$this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
$this->isPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
$this->isContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
@@ -444,6 +448,13 @@ class General extends Component
}
}
public function setSiteType(): void
{
$this->isStatic = $this->siteType !== 'dynamic';
$this->isSpa = $this->siteType === 'spa';
$this->instantSave();
}
public function instantSave()
{
try {
@@ -680,7 +691,10 @@ class General extends Component
if ($this->application->additional_servers->count() === 0) {
foreach ($domains as $domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
}
}
}
@@ -722,7 +736,10 @@ class General extends Component
$this->application->redirect = $this->redirect;
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
if ($has_www === 0 && $this->application->redirect === 'www') {
$this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list and as an A DNS record (if applicable).');
$server = $this->application->destination?->server;
$target = $server ? (serverDnsTargetIp($server) ?? $server->ip) : null;
$dnsHint = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', "You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list ({$dnsHint}).");
return;
}
@@ -847,7 +864,10 @@ class General extends Component
$domain = data_get($service, 'domain');
if ($domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
}
}
}
@@ -20,6 +20,8 @@ class Heading extends Component
public array $parameters;
public string $activeRouteName = '';
protected string $deploymentUuid;
public bool $docker_cleanup = true;
@@ -38,6 +40,7 @@ class Heading extends Component
public function mount()
{
$this->activeRouteName = request()->route()?->getName() ?? '';
$this->parameters = [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
@@ -129,7 +129,10 @@ class Previews extends Component
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
$this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$fqdn->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$success = false;
}
+10 -2
View File
@@ -252,9 +252,17 @@ class BackupEdit extends Component
]);
}
// Instance databases (e.g. coolify-db) have no project/environment.
// Stay on the current page (settings.backup) instead of redirecting.
$project = $database->project();
$environment = $database->environment;
if (! $project || ! $environment) {
return null;
}
return redirect()->route('project.database.backup.executions', [
'project_uuid' => $database->project()->uuid,
'environment_uuid' => $database->environment->uuid,
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $this->backup->uuid,
]);
+55 -12
View File
@@ -2,28 +2,71 @@
namespace App\Livewire\Project;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Index extends Component
{
public $projects;
public $servers;
public $private_keys;
public function mount()
public function mount(): void
{
$this->private_keys = PrivateKey::ownedByCurrentTeamCached();
$this->projects = Project::ownedByCurrentTeamCached();
$this->servers = Server::ownedByCurrentTeamCached();
// Only load what the page renders. Servers/private keys were previously
// hydrated into public Livewire state but never used by the view.
$this->projects = Project::ownedByCurrentTeam()
->with(['environments:id,uuid,name,project_id'])
->withCount([
'applications',
'services',
'postgresqls',
'redis',
'keydbs',
'dragonflies',
'clickhouses',
'mongodbs',
'mysqls',
'mariadbs',
])
->get();
}
public function render()
public function render(): View
{
return view('livewire.project.index');
return view('livewire.project.index', [
'projectsJs' => $this->projects->map(function (Project $project): array {
$firstEnvironment = $project->environments->first();
$resourceCount = collect([
$project->applications_count,
$project->services_count,
$project->postgresqls_count,
$project->redis_count,
$project->keydbs_count,
$project->dragonflies_count,
$project->clickhouses_count,
$project->mongodbs_count,
$project->mysqls_count,
$project->mariadbs_count,
])->sum();
return [
'uuid' => $project->uuid,
'name' => $project->name,
'description' => $project->description,
'href' => $project->navigateTo(),
'environmentCount' => $project->environments->count(),
'resourceCount' => $resourceCount,
'settingsHref' => auth()->user()->can('update', $project)
? route('project.edit', ['project_uuid' => $project->uuid])
: null,
'addResourceHref' => $firstEnvironment && auth()->user()->can('createAnyResource')
? route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
])
: null,
];
})->values()->toArray(),
]);
}
}
+19 -6
View File
@@ -161,13 +161,15 @@ class Select extends Component
[
'id' => 'public',
'name' => 'Public Repository',
'description' => 'You can deploy any kind of public repositories from the supported git providers.',
'description' => 'Connect any public Git repository and let Coolify build and deploy it from source.',
'documentation' => 'https://coolify.io/docs/applications/ci-cd',
'logo' => asset('svgs/git.svg'),
],
[
'id' => 'private-gh-app',
'name' => 'Private Repository (with GitHub App)',
'description' => 'You can deploy public & private repositories through your GitHub Apps.',
'description' => 'Deploy a private GitHub repository with automatic webhooks and pull request support.',
'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/setup-app',
'logo' => asset('svgs/github.svg'),
],
[
@@ -179,7 +181,8 @@ class Select extends Component
[
'id' => 'private-deploy-key',
'name' => 'Private Repository (with Deploy Key)',
'description' => 'You can deploy private repositories with a deploy key.',
'description' => 'Connect a private Git repository over SSH using a repository-scoped deploy key.',
'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/deploy-key',
'logo' => asset('svgs/git.svg'),
],
];
@@ -187,19 +190,22 @@ class Select extends Component
[
'id' => 'dockerfile',
'name' => 'Dockerfile',
'description' => 'You can deploy a simple Dockerfile, without Git.',
'description' => 'Build and deploy an application from a Dockerfile without connecting a Git repository.',
'documentation' => 'https://coolify.io/docs/applications/build-packs/dockerfile',
'logo' => asset('svgs/docker.svg'),
],
[
'id' => 'docker-compose-empty',
'name' => 'Docker Compose Empty',
'description' => 'You can deploy complex application easily with Docker Compose, without Git.',
'description' => 'Create a multi-container application and provide the Docker Compose definition manually.',
'documentation' => 'https://coolify.io/docs/applications/build-packs/docker-compose',
'logo' => asset('svgs/docker.svg'),
],
[
'id' => 'docker-image',
'name' => 'Docker Image',
'description' => 'You can deploy an existing Docker Image from any Registry, without Git.',
'description' => 'Run a prebuilt image from Docker Hub or another compatible container registry.',
'documentation' => 'https://coolify.io/docs/applications',
'logo' => asset('svgs/docker.svg'),
],
];
@@ -286,6 +292,13 @@ class Select extends Component
private function serviceTemplatesLastUpdated(): ?string
{
$fetchedAt = get_service_templates_fetched_at();
if ($fetchedAt instanceof CarbonImmutable) {
return $fetchedAt
->timezone(config('app.timezone'))
->format('M j, Y H:i');
}
return $this->formatLastModified($this->serviceTemplatesPath());
}
+13 -11
View File
@@ -188,24 +188,26 @@ class Index extends Component
'dragonflies' => $this->dragonflies,
'clickhouses' => $this->clickhouses,
'services' => $this->services,
'applicationsJs' => $this->toSearchableArray($this->applications),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls),
'redisJs' => $this->toSearchableArray($this->redis),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs),
'mysqlsJs' => $this->toSearchableArray($this->mysqls),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs),
'keydbsJs' => $this->toSearchableArray($this->keydbs),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses),
'servicesJs' => $this->toSearchableArray($this->services),
'applicationsJs' => $this->toSearchableArray($this->applications, 'application', 'Application'),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'PostgreSQL'),
'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Redis'),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'MongoDB'),
'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'MySQL'),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'MariaDB'),
'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'KeyDB'),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Dragonfly'),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'ClickHouse'),
'servicesJs' => $this->toSearchableArray($this->services, 'service', 'Service'),
]);
}
private function toSearchableArray(Collection $items): array
private function toSearchableArray(Collection $items, string $type, string $typeLabel): array
{
return $items->map(fn ($item) => [
'uuid' => $item->uuid,
'name' => $item->name,
'type' => $type,
'typeLabel' => $item instanceof V5Application ? 'Application (V5)' : $typeLabel,
'fqdn' => $item->fqdn ?? null,
'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null),
'status' => $item->status ?? '',
File diff suppressed because it is too large. Load diff
+12
View File
@@ -282,6 +282,18 @@ class Index extends Component
}
}
public function enablePublicAccess(): void
{
$this->isPublic = true;
$this->instantSave();
}
public function disablePublicAccess(): void
{
$this->isPublic = false;
$this->instantSave();
}
public function instantSave()
{
try {
@@ -6,6 +6,7 @@ use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@@ -29,10 +30,22 @@ class All extends Component
public string $search = '';
public string $environmentFilter = 'all';
public int $page = 1;
public int $perPage = 10;
public bool $is_env_sorting_enabled = false;
public bool $use_build_secrets = false;
/**
* Environment variable rows are loaded after first paint via wire:init
* so the surrounding configuration page can render immediately.
*/
public bool $readyToLoad = false;
protected $listeners = [
'saveKey' => 'submit',
'refreshEnvs',
@@ -41,6 +54,7 @@ class All extends Component
public function updatedSearch(): void
{
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
@@ -51,6 +65,11 @@ class All extends Component
unset($this->hardcodedEnvironmentVariables);
unset($this->hardcodedEnvironmentVariablesPreview);
unset($this->hasEnvironmentVariables);
unset($this->environmentVariableRows);
unset($this->environmentVariablePageRows);
unset($this->environmentVariableRowCount);
unset($this->environmentVariableLastPage);
unset($this->currentEnvironmentVariablePage);
}
public function mount()
@@ -63,7 +82,25 @@ class All extends Component
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
}
$this->getDevView();
// Intentionally skip loading env vars / developer-view bulk text here.
// loadEnvironmentVariables() is triggered from the frontend via wire:init.
}
/**
* Frontend-initiated load of environment variables after the page shell paints.
*/
public function loadEnvironmentVariables(): void
{
if ($this->readyToLoad) {
return;
}
$this->readyToLoad = true;
$this->clearEnvironmentVariableCaches();
if ($this->view === 'dev') {
$this->getDevView();
}
}
public function instantSave()
@@ -71,10 +108,14 @@ class All extends Component
try {
$this->authorize('manageEnvironment', $this->resource);
$this->page = 1;
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
$this->resource->settings->use_build_secrets = $this->use_build_secrets;
$this->resource->settings->save();
$this->getDevView();
$this->clearEnvironmentVariableCaches();
if ($this->readyToLoad && $this->view === 'dev') {
$this->getDevView();
}
$this->dispatch('success', 'Environment variable settings updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
@@ -84,20 +125,30 @@ class All extends Component
public function getEnvironmentVariablesProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getEnvironmentVariables(false);
}
public function getEnvironmentVariablesPreviewProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getEnvironmentVariables(true);
}
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true, bool $withValue = true): Collection
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
// Full-value loads (dev view / tests) still use relationship queries.
// List/table pagination uses fetchManagedEnvironmentVariables() without values.
$query = $isPreview
? $this->resource->environment_variables_preview()
: $this->resource->environment_variables();
@@ -116,7 +167,15 @@ class All extends Component
$query->orderBy('order');
}
return $this->nullLockedValues($query->get());
if (! $withValue) {
$query->select(self::LIST_COLUMNS);
}
$variables = $query->get()->each(fn (EnvironmentVariable $environmentVariable) => $environmentVariable->setAppends([]));
return $withValue
? $this->nullLockedValues($variables)
: $variables;
}
private function searchTerm(): string
@@ -131,14 +190,11 @@ class All extends Component
public function getHasEnvironmentVariablesProperty(): bool
{
$hasPreviewEnvironmentVariables = $this->supportsPreviewEnvironmentVariables() && (
$this->environmentVariablesPreview->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty()
);
if (! $this->readyToLoad) {
return false;
}
return $this->environmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariables->isNotEmpty() ||
$hasPreviewEnvironmentVariables;
return $this->environmentVariableRowCount > 0;
}
private function nullLockedValues($envs)
@@ -162,14 +218,341 @@ class All extends Component
public function getHardcodedEnvironmentVariablesProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getHardcodedVariables(false);
}
public function getHardcodedEnvironmentVariablesPreviewProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getHardcodedVariables(true);
}
/**
* Full in-memory row list. Prefer page/count helpers for UI rendering so large
* variable sets stay cheap; this remains for tests and non-paginated callers.
*/
public function getEnvironmentVariableRowsProperty(): Collection
{
if (! $this->readyToLoad) {
return collect();
}
$rows = collect();
foreach ($this->environmentVariableSegments() as $segment) {
if ($segment['kind'] === 'managed') {
foreach ($this->fetchManagedEnvironmentVariables($segment['is_preview'], null, null) as $environmentVariable) {
$rows->push($this->managedEnvironmentVariableRow($environmentVariable));
}
continue;
}
$hardcoded = $segment['is_preview']
? $this->hardcodedEnvironmentVariablesPreview
: $this->hardcodedEnvironmentVariables;
foreach ($hardcoded->values() as $index => $environmentVariable) {
$rows->push($this->hardcodedEnvironmentVariableRow(
$environmentVariable,
$segment['is_preview'],
$index,
));
}
}
return $rows->values();
}
public function getEnvironmentVariablePageRowsProperty(): Collection
{
if (! $this->readyToLoad) {
return collect();
}
$page = $this->currentEnvironmentVariablePage;
$offset = max(0, ($page - 1) * $this->perPage);
$remaining = $this->perPage;
$rows = collect();
foreach ($this->environmentVariableSegments() as $segment) {
$segmentCount = $segment['count'];
if ($segmentCount === 0) {
continue;
}
if ($offset >= $segmentCount) {
$offset -= $segmentCount;
continue;
}
$take = min($remaining, $segmentCount - $offset);
if ($segment['kind'] === 'managed') {
$variables = $this->fetchManagedEnvironmentVariables($segment['is_preview'], $offset, $take);
foreach ($variables as $environmentVariable) {
$rows->push($this->managedEnvironmentVariableRow($environmentVariable));
}
} else {
$hardcoded = $segment['is_preview']
? $this->hardcodedEnvironmentVariablesPreview
: $this->hardcodedEnvironmentVariables;
foreach ($hardcoded->slice($offset, $take)->values() as $index => $environmentVariable) {
$rows->push($this->hardcodedEnvironmentVariableRow(
$environmentVariable,
$segment['is_preview'],
$offset + $index,
));
}
}
$remaining -= $take;
$offset = 0;
if ($remaining <= 0) {
break;
}
}
return $rows->values();
}
public function getEnvironmentVariableRowCountProperty(): int
{
if (! $this->readyToLoad) {
return 0;
}
return collect($this->environmentVariableSegments())->sum('count');
}
public function getEnvironmentVariableLastPageProperty(): int
{
return max(1, (int) ceil($this->environmentVariableRowCount / $this->perPage));
}
public function getCurrentEnvironmentVariablePageProperty(): int
{
return min($this->page, $this->environmentVariableLastPage);
}
public function setEnvironmentFilter(string $filter): void
{
if (! in_array($filter, ['all', 'production', 'preview'], true)) {
return;
}
$this->environmentFilter = $filter;
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function setEnvironmentVariablePage(int $page): void
{
$this->page = max(1, min($page, $this->environmentVariableLastPage));
$this->clearEnvironmentVariableCaches();
}
public function previousEnvironmentVariablePage(): void
{
$this->setEnvironmentVariablePage($this->currentEnvironmentVariablePage - 1);
}
public function nextEnvironmentVariablePage(): void
{
$this->setEnvironmentVariablePage($this->currentEnvironmentVariablePage + 1);
}
/**
* Ordered segments used for pagination: production managed → production hardcoded
* → preview managed → preview hardcoded (matching the historical table order).
*
* @return list<array{kind: string, is_preview: bool, count: int}>
*/
private function environmentVariableSegments(): array
{
$includeProduction = $this->environmentFilter === 'all' || $this->environmentFilter === 'production';
$includePreview = $this->supportsPreviewEnvironmentVariables()
&& ($this->environmentFilter === 'all' || $this->environmentFilter === 'preview');
$segments = [];
if ($includeProduction) {
$segments[] = [
'kind' => 'managed',
'is_preview' => false,
'count' => $this->countManagedEnvironmentVariables(false),
];
if ($this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => false,
'count' => $this->hardcodedEnvironmentVariables->count(),
];
}
}
if ($includePreview) {
$segments[] = [
'kind' => 'managed',
'is_preview' => true,
'count' => $this->countManagedEnvironmentVariables(true),
];
if ($this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => true,
'count' => $this->hardcodedEnvironmentVariablesPreview->count(),
];
}
}
return $segments;
}
private function managedEnvironmentVariablesQuery(bool $isPreview): Builder
{
$query = EnvironmentVariable::query()
->where('resourceable_type', $this->resource->getMorphClass())
->where('resourceable_id', $this->resource->id)
->where('is_preview', $isPreview);
$query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($this->searchTerm() !== '') {
$escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\');
$query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']);
}
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
} else {
$query->orderBy('order')->orderBy('id');
}
return $query;
}
private function countManagedEnvironmentVariables(bool $isPreview): int
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return 0;
}
return $this->managedEnvironmentVariablesQuery($isPreview)->count();
}
/**
* Columns needed to render the table row / nested Show component without decrypting secrets.
* The encrypted `value` column is intentionally omitted until edit/dev view loads it.
*
* @var list<string>
*/
private const LIST_COLUMNS = [
'id',
'uuid',
'key',
'comment',
'order',
'is_preview',
'is_multiline',
'is_literal',
'is_runtime',
'is_buildtime',
'is_required',
'is_shown_once',
'is_shared',
'resourceable_type',
'resourceable_id',
'created_at',
'updated_at',
'version',
];
private function fetchManagedEnvironmentVariables(?bool $isPreview, ?int $offset, ?int $limit, bool $withValue = false): Collection
{
if ($isPreview === true && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
if ($isPreview === null) {
$variables = collect();
if ($this->environmentFilter === 'all' || $this->environmentFilter === 'production') {
$variables = $variables->concat($this->fetchManagedEnvironmentVariables(false, null, null, $withValue));
}
if ($this->supportsPreviewEnvironmentVariables()
&& ($this->environmentFilter === 'all' || $this->environmentFilter === 'preview')) {
$variables = $variables->concat($this->fetchManagedEnvironmentVariables(true, null, null, $withValue));
}
return $variables->values();
}
$query = $this->managedEnvironmentVariablesQuery($isPreview);
if (! $withValue) {
$query->select(self::LIST_COLUMNS);
}
if ($offset !== null) {
$query->skip($offset);
}
if ($limit !== null) {
$query->take($limit);
}
$variables = $query->get()->each(function (EnvironmentVariable $environmentVariable) {
// Prevent accidental real_value / is_shared accessor work during Livewire hydration.
$environmentVariable->setAppends([]);
});
return $withValue
? $this->nullLockedValues($variables)
: $variables;
}
private function managedEnvironmentVariableRow(EnvironmentVariable $environmentVariable): array
{
return [
'id' => 'environment-'.$environmentVariable->id,
'kind' => 'managed',
'scope' => $environmentVariable->is_preview ? 'preview' : 'production',
'environmentVariable' => $environmentVariable,
];
}
private function hardcodedEnvironmentVariableRow(array $environmentVariable, bool $isPreview, int $index): array
{
$scope = $isPreview ? 'preview' : 'production';
return [
'id' => 'hardcoded-'.$scope.'-'.$environmentVariable['key'].'-'.($environmentVariable['service_name'] ?? 'default').'-'.$index,
'kind' => 'hardcoded',
'scope' => $scope,
'environmentVariable' => $environmentVariable,
];
}
private function showsHardcodedEnvironmentVariables(): bool
{
return $this->resource->type() === 'service' || $this->resource?->build_pack === 'dockercompose';
}
protected function getHardcodedVariables(bool $isPreview)
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
@@ -256,13 +639,17 @@ class All extends Component
public function switch()
{
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
if ($this->view === 'dev') {
$this->ensureEnvironmentVariablesLoaded();
$this->getDevView();
}
}
public function submit($data = null)
{
try {
$this->authorize('manageEnvironment', $this->resource);
$this->ensureEnvironmentVariablesLoaded();
if ($data === null) {
$this->handleBulkSubmit();
} else {
@@ -270,7 +657,9 @@ class All extends Component
}
$this->updateOrder();
$this->getDevView();
if ($this->view === 'dev') {
$this->getDevView();
}
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
@@ -278,6 +667,13 @@ class All extends Component
}
}
private function ensureEnvironmentVariablesLoaded(): void
{
if (! $this->readyToLoad) {
$this->loadEnvironmentVariables();
}
}
private function updateOrder()
{
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
@@ -495,7 +891,10 @@ class All extends Component
public function refreshEnvs()
{
$this->resource->refresh();
$this->readyToLoad = true;
$this->clearEnvironmentVariableCaches();
$this->getDevView();
if ($this->view === 'dev') {
$this->getDevView();
}
}
}
@@ -35,6 +35,10 @@ class Show extends Component
public string $type;
public int $tableAlphabeticalOrder = 0;
public int $tableCreationOrder = 0;
public string $key;
public ?string $value = null;
@@ -63,6 +67,18 @@ class Show extends Component
public bool $isValueHidden = false;
/**
* Decrypted value / real_value are only needed in the edit modal (or after save).
* Keeping them unloaded for table rows avoids decrypting every visible env on each page change.
*/
public bool $valuesLoaded = false;
/**
* Entangled with the edit modal open state so the modal stays open across the
* async loadValues() re-render (open immediately, decrypt after).
*/
public bool $editorOpen = false;
public array $problematicVariables = [];
protected $listeners = [
@@ -116,10 +132,33 @@ class Show extends Component
if (! $this->env->exists || ! $this->env->fresh()) {
return;
}
$this->valuesLoaded = false;
$this->syncData();
$this->checkEnvs();
}
/**
* Decrypt and resolve values only when the edit modal is opened.
*/
public function loadValues(): void
{
if ($this->valuesLoaded) {
return;
}
// List queries omit the encrypted value column; refresh so edit has a full model.
if ($this->env->exists) {
$fresh = $this->env->fresh();
if ($fresh) {
$fresh->setAppends([]);
$this->env = $fresh;
}
}
$this->hydrateValueFields();
$this->valuesLoaded = true;
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
@@ -149,29 +188,55 @@ class Show extends Component
$this->env->is_literal = $this->is_literal;
$this->env->is_shown_once = $this->is_shown_once;
$this->env->save();
$this->valuesLoaded = true;
} else {
// Table metadata only — never decrypt here. Values load via loadValues().
$this->env->setAppends([]);
$this->key = $this->env->key;
$this->value = $this->env->value;
$this->comment = $this->env->comment;
$this->is_multiline = $this->env->is_multiline;
$this->is_literal = $this->env->is_literal;
$this->is_shown_once = $this->env->is_shown_once;
$this->is_runtime = $this->env->is_runtime ?? true;
$this->is_buildtime = $this->env->is_buildtime ?? true;
$this->is_required = $this->env->is_required ?? false;
$this->is_really_required = $this->env->is_really_required ?? false;
$this->is_shared = $this->env->is_shared ?? false;
$this->real_value = $this->env->real_value;
$this->is_multiline = (bool) $this->env->is_multiline;
$this->is_literal = (bool) $this->env->is_literal;
$this->is_shown_once = (bool) $this->env->is_shown_once;
$this->is_runtime = (bool) ($this->env->is_runtime ?? true);
$this->is_buildtime = (bool) ($this->env->is_buildtime ?? true);
$this->is_required = (bool) ($this->env->is_required ?? false);
// Use the stored column, not the value-based accessor (that decrypts).
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
$this->isValueHidden = auth()->user()?->isMember() ?? false;
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
if ($this->valuesLoaded) {
$this->hydrateValueFields();
} else {
$this->value = null;
$this->real_value = null;
// Required badge: without decrypting, show when flagged required.
// Exact empty-value state is refined when the edit modal opens.
$this->is_really_required = $this->is_required;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
}
}
private function hydrateValueFields(): void
{
$this->value = $this->env->value;
$this->is_shared = (bool) ($this->env->is_shared ?? false);
if ($this->is_shared) {
$this->real_value = $this->env->real_value;
$this->is_really_required = $this->is_required && blank($this->real_value);
} else {
$this->real_value = null;
$this->is_really_required = $this->is_required && blank($this->value);
}
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
$this->value = null;
$this->real_value = null;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
}
public function checkEnvs()
{
$this->isDisabled = false;
@@ -215,6 +280,7 @@ class Show extends Component
{
try {
$this->authorize('update', $this->env);
$this->loadValues();
if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) {
$oldValue = $this->env->getOriginal('value');
@@ -238,7 +304,23 @@ class Show extends Component
#[Computed]
public function availableSharedVariables(): array
{
// Shared across all Show row components in the same request (edit modals).
static $requestCache = [];
$team = currentTeam();
$cacheKey = implode('|', [
$team?->id ?? 'none',
data_get($this->parameters, 'project_uuid', ''),
data_get($this->parameters, 'environment_uuid', ''),
data_get($this->parameters, 'server_uuid', ''),
data_get($this->parameters, 'application_uuid', ''),
data_get($this->parameters, 'service_uuid', ''),
]);
if (array_key_exists($cacheKey, $requestCache)) {
return $requestCache[$cacheKey];
}
$result = [
'team' => [],
'project' => [],
@@ -248,7 +330,7 @@ class Show extends Component
// Early return if no team
if (! $team) {
return $result;
return $requestCache[$cacheKey] = $result;
}
// Check if user can view team variables
@@ -359,7 +441,7 @@ class Show extends Component
}
}
return $result;
return $requestCache[$cacheKey] = $result;
}
public function delete()
@@ -16,6 +16,8 @@ class ShowHardcoded extends Component
public ?string $serviceName = null;
public bool $isPreview = false;
public function mount()
{
$this->key = $this->env['key'];
+24 -7
View File
@@ -80,14 +80,31 @@ class Logs extends Component
]);
return $containers->toArray();
} else {
$containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true);
if ($containers && $containers->count() > 0) {
return $containers->sort()->toArray();
}
return [];
}
// Docker labels differ by resource type:
// applications → coolify.applicationId, services → coolify.serviceId, databases → coolify.databaseId
$containers = match (true) {
$this->resource instanceof Application => getCurrentApplicationContainerStatus(
$server,
$this->resource->id,
includePullrequests: true
),
$this->resource instanceof Service => getCurrentServiceContainerStatus(
$server,
$this->resource->id
),
default => getCurrentDatabaseContainerStatus(
$server,
$this->resource->id
),
};
if ($containers && $containers->count() > 0) {
return $containers->sort()->toArray();
}
return [];
} catch (\Exception $e) {
// Log error but don't fail the entire operation
+2
View File
@@ -17,6 +17,8 @@ class Terminal extends Component
public bool $isTerminalConnected = false;
public string $variant = 'default';
private function checkShellAvailability(Server $server, string $container): bool
{
$escapedContainer = escapeshellarg($container);
+1 -1
View File
@@ -62,7 +62,7 @@ class Webhooks extends Component
'manual_webhook_secret_bitbucket' => $this->bitbucketManualWebhookSecret,
'manual_webhook_secret_gitea' => $this->giteaManualWebhookSecret,
]);
$this->dispatch('success', 'Secret Saved.');
$this->dispatch('success', 'Webhook secrets saved.');
} catch (\Exception $e) {
return handleError($e, $this);
}
+63 -3
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project;
use App\Models\Environment;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -34,7 +35,25 @@ class Show extends Component
public function mount(string $project_uuid)
{
try {
$this->project = Project::where('team_id', currentTeam()->id)->where('uuid', $project_uuid)->firstOrFail();
$this->project = Project::where('team_id', currentTeam()->id)
->where('uuid', $project_uuid)
->with([
'environments' => fn ($query) => $query
->withCount([
'applications',
'services',
'postgresqls',
'redis',
'keydbs',
'dragonflies',
'clickhouses',
'mongodbs',
'mysqls',
'mariadbs',
])
->orderBy('created_at'),
])
->firstOrFail();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -68,8 +87,49 @@ class Show extends Component
]);
}
public function render()
public function render(): View
{
return view('livewire.project.show');
$canUpdateProject = auth()->user()->can('update', $this->project);
$canCreateResource = auth()->user()->can('createAnyResource');
return view('livewire.project.show', [
'environmentsJs' => $this->project->environments->map(function (Environment $environment) use ($canCreateResource, $canUpdateProject): array {
$resourceCount = collect([
$environment->applications_count,
$environment->services_count,
$environment->postgresqls_count,
$environment->redis_count,
$environment->keydbs_count,
$environment->dragonflies_count,
$environment->clickhouses_count,
$environment->mongodbs_count,
$environment->mysqls_count,
$environment->mariadbs_count,
])->sum();
return [
'uuid' => $environment->uuid,
'name' => $environment->name,
'description' => $environment->description,
'resourceCount' => $resourceCount,
'href' => route('project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
]),
'settingsHref' => $canUpdateProject
? route('project.environment.edit', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
])
: null,
'addResourceHref' => $canCreateResource
? route('project.resource.create', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
])
: null,
];
})->values()->toArray(),
]);
}
}
+5
View File
@@ -45,6 +45,11 @@ class Delete extends Component
}
try {
$this->authorize('delete', $this->server);
if ($this->server->is_coolify_host) {
$this->dispatch('error', 'The Coolify host server cannot be deleted.');
return;
}
if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) {
$this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".');
+2 -2
View File
@@ -177,11 +177,11 @@ class LogDrains extends Component
}
}
public function submit(string $type)
public function submit()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true, $type);
$this->syncData(true);
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
+25
View File
@@ -32,6 +32,8 @@ class Navbar extends Component
public bool $restartInitiated = false;
public array $serverSwitcherOptions = [];
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -49,6 +51,29 @@ class Navbar extends Component
$this->currentRoute = request()->route()->getName();
$this->serverIp = $this->server->id === 0 ? base_ip() : $this->server->ip;
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$routeParameters = request()->route()?->parameters() ?? [];
$routeName = request()->route()?->getName();
$this->serverSwitcherOptions = auth()->user()->currentTeam()->servers()
->orderBy('name')
->get()
->map(function (Server $server) use ($routeName, $routeParameters): array {
$parameters = [...$routeParameters, 'server_uuid' => $server->uuid];
try {
$href = $routeName ? route($routeName, $parameters) : route('server.show', $server->uuid);
} catch (\Throwable) {
$href = route('server.show', $server->uuid);
}
return [
'uuid' => $server->uuid,
'name' => $server->name,
'href' => $href,
'functional' => $server->isFunctional(),
];
})
->values()
->all();
$this->loadProxyConfiguration();
}
+36 -27
View File
@@ -48,6 +48,8 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $webhook_allow_localhost;
public ?string $domain_connect_private_key = null;
public function rules()
{
return [
@@ -63,6 +65,7 @@ class Advanced extends Component
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'nullable|string',
'webhook_allow_localhost' => 'boolean',
'domain_connect_private_key' => 'nullable|string',
];
}
@@ -84,6 +87,8 @@ class Advanced extends Component
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
$this->webhook_allowed_internal_hosts = collect($this->settings->webhook_allowed_internal_hosts ?? [])->implode(',');
$this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false;
// Do not prefill the secret into the form; only update when the admin pastes a new value.
$this->domain_connect_private_key = null;
}
public function submit()
@@ -155,6 +160,11 @@ class Advanced extends Component
return;
}
if (isCloud() && filled($this->domain_connect_private_key)) {
$this->settings->domain_connect_private_key = $this->normalizeDomainConnectPrivateKey($this->domain_connect_private_key);
$this->domain_connect_private_key = null;
}
$this->instantSave($webhookAllowedInternalHosts);
} catch (\Exception $e) {
return handleError($e, $this);
@@ -187,6 +197,32 @@ class Advanced extends Component
}
}
public function clearDomainConnectPrivateKey(): void
{
try {
if (! isCloud()) {
return;
}
$this->authorize('update', $this->settings);
$this->settings->domain_connect_private_key = null;
$this->settings->save();
$this->domain_connect_private_key = null;
$this->dispatch('success', 'Domain Connect private key removed.');
} catch (\Exception $e) {
handleError($e, $this);
}
}
private function normalizeDomainConnectPrivateKey(string $key): string
{
$key = str_replace(["\r\n", "\r"], "\n", trim($key));
if (! str_contains($key, "\n") && str_contains($key, '\\n')) {
$key = str_replace('\\n', "\n", $key);
}
return $key;
}
/**
* @return array<int, string>|false
*/
@@ -230,33 +266,6 @@ class Advanced extends Component
return filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
}
public function toggleRegistration($password): bool
{
if (! verifyPasswordConfirmation($password, $this)) {
return false;
}
$this->settings->is_registration_enabled = $this->is_registration_enabled = true;
$this->settings->save();
$this->dispatch('success', 'Registration has been enabled.');
return true;
}
public function toggleTwoStepConfirmation($password): bool
{
$this->authorize('update', $this->settings);
if (! verifyPasswordConfirmation($password, $this)) {
return false;
}
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation = true;
$this->settings->save();
$this->dispatch('success', 'Two step confirmation has been disabled.');
return true;
}
public function render()
{
return view('livewire.settings.advanced');
+3 -1
View File
@@ -141,7 +141,9 @@ class Index extends Component
if ($this->settings->is_dns_validation_enabled && $this->fqdn && $this->server) {
if (! validateDNSEntry($this->fqdn, $this->server)) {
$this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$target = serverDnsTargetIp($this->server) ?? $this->server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', "Validating DNS failed.<br><br>{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$error_show = true;
}
}
+34 -2
View File
@@ -28,6 +28,10 @@ class ScheduledJobs extends Component
public string $filterDate = 'last_24h';
public string $search = '';
public string $sortOrder = 'newest';
public int $skipPage = 0;
public int $skipDefaultTake = 20;
@@ -76,6 +80,16 @@ class ScheduledJobs extends Component
$this->loadData();
}
public function updatedSearch(): void
{
$this->loadData();
}
public function updatedSortOrder(): void
{
$this->loadData();
}
public function skipNextPage(): void
{
$this->skipPage += $this->skipDefaultTake;
@@ -241,8 +255,26 @@ class ScheduledJobs extends Component
$cleanups = $this->getCleanupExecutions($dateFrom, $teamId);
}
return $backups->concat($tasks)->concat($cleanups)
->sortByDesc('created_at')
$executions = $backups->concat($tasks)->concat($cleanups);
if (filled($this->search)) {
$search = str($this->search)->lower()->trim()->toString();
$executions = $executions->filter(function (array $execution) use ($search): bool {
return collect([
$execution['type'],
$execution['resource_name'],
$execution['resource_type'],
$execution['server_name'],
$execution['message'],
])->filter()->contains(
fn ($value): bool => str((string) $value)->lower()->contains($search)
);
});
}
return ($this->sortOrder === 'oldest'
? $executions->sortBy('created_at')
: $executions->sortByDesc('created_at'))
->values()
->take(100);
}
+10
View File
@@ -143,6 +143,16 @@ class SettingsEmail extends Component
}
}
public function instantSaveSmtp(): void
{
$this->instantSave('SMTP');
}
public function instantSaveResend(): void
{
$this->instantSave('Resend');
}
public function submitSmtp()
{
try {
+2
View File
@@ -353,6 +353,8 @@ class Change extends Component
$this->activeTab = 'permissions';
} elseif ($routeName === 'source.github.resources') {
$this->activeTab = 'resources';
} elseif ($routeName === 'source.github.danger') {
$this->activeTab = 'danger';
} else {
$this->activeTab = 'general';
}
@@ -73,4 +73,9 @@ class PricingPlans extends Component
return redirect($session->url, 303);
}
public function render()
{
return view('livewire.subscription.pricing-plans');
}
}
+34 -2
View File
@@ -40,14 +40,26 @@ class Show extends Component
public function mount()
{
try {
$this->tags = Tag::ownedByCurrentTeam()->get()->unique('name')->sortBy('name');
$this->tags = Tag::ownedByCurrentTeam()
->withCount(['applications', 'services'])
->get()
->unique('name')
->sortBy('name')
->values();
if (str($this->tagName)->isNotEmpty()) {
$tag = $this->tags->where('name', $this->tagName)->first();
if (! $tag) {
return redirect()->route('tags.show');
}
$this->webhook = generateTagDeployWebhook($tag->name);
$this->applications = $tag->applications()->get();
$this->services = $tag->services()->get();
$this->tag = $tag;
$this->getDeployments();
} else {
$this->deploymentsPerTagPerServer = [];
}
} catch (\Exception $e) {
return handleError($e, $this);
@@ -57,6 +69,12 @@ class Show extends Component
public function getDeployments()
{
try {
if (! $this->applications) {
$this->deploymentsPerTagPerServer = [];
return;
}
$resource_ids = $this->applications->pluck('id');
$this->deploymentsPerTagPerServer = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('application_id', $resource_ids)->get([
'id',
@@ -99,6 +117,20 @@ class Show extends Component
public function render()
{
return view('livewire.tags.show');
return view('livewire.tags.show', [
'tagsJs' => ($this->tags ?? collect())->map(function (Tag $tag): array {
$applicationsCount = (int) data_get($tag, 'applications_count', 0);
$servicesCount = (int) data_get($tag, 'services_count', 0);
return [
'id' => $tag->id,
'name' => $tag->name,
'href' => route('tags.show', ['tagName' => $tag->name]),
'applicationsCount' => $applicationsCount,
'servicesCount' => $servicesCount,
'resourceCount' => $applicationsCount + $servicesCount,
];
})->values()->toArray(),
]);
}
}
+48 -27
View File
@@ -4,55 +4,51 @@ namespace App\Livewire\Team;
use App\Models\User;
use Livewire\Component;
use Livewire\WithPagination;
class AdminView extends Component
{
public $users;
use WithPagination;
public ?string $search = '';
public string $search = '';
public bool $lots_of_users = false;
public string $teamFilter = 'all';
private $number_of_users_to_show = 20;
public string $sort = 'name_asc';
public function mount()
{
if (! isInstanceAdmin()) {
return redirect()->route('dashboard');
}
$this->getUsers();
}
public function submitSearch()
public function updatedSearch(): void
{
if (! isInstanceAdmin()) {
return;
}
if ($this->search !== '') {
$this->users = User::where(function ($query) {
$query->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
})->get()->filter(function ($user) {
return $user->id !== auth()->id();
});
} else {
$this->getUsers();
}
$this->resetPage();
}
public function getUsers()
public function updatedTeamFilter(): void
{
$this->resetPage();
}
public function updatedSort(): void
{
$this->resetPage();
}
public function submitSearch(): void
{
if (! isInstanceAdmin()) {
return;
}
$users = User::where('id', '!=', auth()->id())->get();
if ($users->count() > $this->number_of_users_to_show) {
$this->lots_of_users = true;
$this->users = $users->take($this->number_of_users_to_show);
} else {
$this->lots_of_users = false;
$this->users = $users;
}
$this->resetPage();
}
public function delete($id, $password, $selectedActions = [])
@@ -76,7 +72,7 @@ class AdminView extends Component
try {
$user->delete();
$this->getUsers();
$this->resetPage();
return true;
} catch (\Exception $e) {
@@ -86,6 +82,31 @@ class AdminView extends Component
public function render()
{
return view('livewire.team.admin-view');
$search = trim($this->search);
$teamId = currentTeam()->id;
$users = User::query()
->where('id', '!=', auth()->id())
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
})
->when($this->teamFilter === 'current', function ($query) use ($teamId): void {
$query->whereHas('teams', fn ($teamQuery) => $teamQuery->where('teams.id', $teamId));
})
->when($this->teamFilter === 'outside', function ($query) use ($teamId): void {
$query->whereDoesntHave('teams', fn ($teamQuery) => $teamQuery->where('teams.id', $teamId));
})
->when($this->sort === 'name_desc', fn ($query) => $query->orderByDesc('name'))
->when($this->sort === 'email_asc', fn ($query) => $query->orderBy('email'))
->when($this->sort === 'email_desc', fn ($query) => $query->orderByDesc('email'))
->when($this->sort === 'name_asc', fn ($query) => $query->orderBy('name'))
->orderBy('id')
->paginate(10);
return view('livewire.team.admin-view', [
'users' => $users,
]);
}
}
+1
View File
@@ -33,6 +33,7 @@ class Create extends Component
'name' => $this->name,
'description' => $this->description,
'personal_team' => false,
'is_mcp_server_enabled' => true,
]);
auth()->user()->teams()->attach($team, ['role' => 'admin']);
refreshSession($team);
+3 -1
View File
@@ -66,7 +66,9 @@ class Index extends Component
// Sync FROM model (on load/refresh)
$this->name = $this->team->name;
$this->description = $this->team->description;
$this->is_mcp_server_enabled = $this->team->is_mcp_server_enabled;
// Null can appear after Team::create() when the DB default is not
// hydrated onto the in-memory model stored in session.
$this->is_mcp_server_enabled = (bool) ($this->team->is_mcp_server_enabled ?? true);
}
}
+93 -5
View File
@@ -181,6 +181,7 @@ class Application extends BaseModel
'docker_compose',
'docker_compose_raw',
'docker_compose_domains',
'domain_dns_statuses',
'docker_compose_custom_start_command',
'docker_compose_custom_build_command',
'swarm_replicas',
@@ -233,6 +234,7 @@ class Application extends BaseModel
'docker_compose',
'docker_compose_raw',
'custom_labels',
'domain_dns_statuses',
];
protected function casts(): array
@@ -243,6 +245,7 @@ class Application extends BaseModel
'manual_webhook_secret_gitlab' => 'encrypted',
'manual_webhook_secret_bitbucket' => 'encrypted',
'manual_webhook_secret_gitea' => 'encrypted',
'domain_dns_statuses' => 'array',
'restart_count' => 'integer',
'max_restart_count' => 'integer',
'last_restart_at' => 'datetime',
@@ -1097,16 +1100,101 @@ class Application extends BaseModel
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('created_at', '>=', now()->subDays(7))->orderBy('created_at', 'desc')->get();
}
public function deployments(int $skip = 0, int $take = 10, ?string $pullRequestId = null)
{
$deployments = ApplicationDeploymentQueue::where('application_id', $this->id)->orderBy('created_at', 'desc');
/**
* @return array{count: int, deployments: Collection<int, ApplicationDeploymentQueue>}
*/
public function deployments(
int $skip = 0,
int $take = 10,
?string $pullRequestId = null,
?string $search = null,
string $filter = 'all',
string $sort = 'newest',
): array {
$deployments = ApplicationDeploymentQueue::query()
->where('application_id', $this->id);
if ($pullRequestId) {
$deployments = $deployments->where('pull_request_id', $pullRequestId);
$deployments->where('pull_request_id', $pullRequestId);
}
$search = trim((string) $search);
if ($search !== '') {
$normalizedSearch = Str::lower($search);
$statusAliases = [
'success' => ApplicationDeploymentStatus::FINISHED->value,
'in progress' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'cancelled' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
];
$deployments->where(function ($query) use ($search, $normalizedSearch, $statusAliases) {
$query
->whereLike('deployment_uuid', "%{$search}%")
->orWhereLike('commit', "%{$search}%")
->orWhereLike('commit_message', "%{$search}%")
->orWhereLike('server_name', "%{$search}%")
->orWhereLike('status', "%{$search}%");
if (isset($statusAliases[$normalizedSearch])) {
$query->orWhere('status', $statusAliases[$normalizedSearch]);
}
if (is_numeric($search) && (int) $search > 0) {
$query->orWhere('pull_request_id', (int) $search);
}
match ($normalizedSearch) {
'pull request', 'pull requests', 'pr' => $query->orWhere('pull_request_id', '>', 0),
'webhook', 'webhooks' => $query->orWhere('is_webhook', true),
'rollback', 'rollbacks' => $query->orWhere('rollback', true),
'api' => $query->orWhere('is_api', true),
'manual' => $query->orWhere(function ($sourceQuery) {
$sourceQuery
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', false)
->where('is_api', false);
}),
default => null,
};
});
}
if (Str::startsWith($filter, 'status:')) {
$deployments->where('status', Str::after($filter, 'status:'));
}
if (Str::startsWith($filter, 'source:')) {
match (Str::after($filter, 'source:')) {
'pull-request' => $deployments->where('pull_request_id', '>', 0),
'webhook' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', true),
'rollback' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', true),
'api' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', false)
->where('is_api', true),
'manual' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', false)
->where('is_api', false),
default => null,
};
}
$count = $deployments->count();
$deployments = $deployments->skip($skip)->take($take)->get();
$deployments = $deployments
->orderBy('created_at', $sort === 'oldest' ? 'asc' : 'desc')
->orderBy('id', $sort === 'oldest' ? 'asc' : 'desc')
->skip($skip)
->take($take)
->get();
return [
'count' => $count,
+3
View File
@@ -33,6 +33,7 @@ class InstanceSettings extends Model
'resend_api_key',
'is_dns_validation_enabled',
'custom_dns_servers',
'domain_connect_private_key',
'instance_name',
'is_api_enabled',
'allowed_ips',
@@ -58,6 +59,7 @@ class InstanceSettings extends Model
'smtp_username',
'smtp_password',
'resend_api_key',
'domain_connect_private_key',
'sentinel_token',
];
@@ -74,6 +76,7 @@ class InstanceSettings extends Model
'resend_enabled' => 'boolean',
'resend_api_key' => 'encrypted',
'domain_connect_private_key' => 'encrypted',
'allowed_ip_ranges' => 'array',
'is_auto_update_enabled' => 'boolean',
+18
View File
@@ -17,6 +17,8 @@ class ServiceApplication extends BaseModel
'human_name',
'description',
'fqdn',
'redirect',
'domain_dns_statuses',
'ports',
'exposes',
'status',
@@ -31,6 +33,22 @@ class ServiceApplication extends BaseModel
'is_migrated',
];
/**
* Internal DNS check cache — not part of the public API surface.
*
* @var list<string>
*/
protected $hidden = [
'domain_dns_statuses',
];
protected function casts(): array
{
return [
'domain_dns_statuses' => 'array',
];
}
protected static function booted()
{
static::deleting(function ($service) {
+5 -1
View File
@@ -8,8 +8,8 @@ use App\Jobs\V5TeardownTeamJob;
use App\Notifications\Channels\SendsDiscord;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\Channels\SendsPushover;
use App\Support\V5\V5Feature;
use App\Notifications\Channels\SendsSlack;
use App\Support\V5\V5Feature;
use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -52,6 +52,10 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
'is_mcp_server_enabled',
];
protected $attributes = [
'is_mcp_server_enabled' => true,
];
protected $casts = [
'personal_team' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
-5
View File
@@ -13,17 +13,12 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\Sanctum;
use Laravel\Telescope\TelescopeServiceProvider;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
if (App::isLocal()) {
$this->app->register(TelescopeServiceProvider::class);
}
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
}
@@ -1,67 +0,0 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Laravel\Telescope\TelescopeApplicationServiceProvider;
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// Telescope::night();
$this->hideSensitiveRequestDetails();
$isLocal = $this->app->environment('local');
Telescope::filter(function (IncomingEntry $entry) use ($isLocal) {
return $isLocal ||
$entry->isReportableException() ||
$entry->isFailedRequest() ||
$entry->isFailedJob() ||
$entry->isScheduledTask() ||
$entry->hasMonitoredTag();
});
}
/**
* Prevent sensitive request details from being logged by Telescope.
*/
protected function hideSensitiveRequestDetails(): void
{
if ($this->app->environment('local')) {
return;
}
Telescope::hideRequestParameters(['_token']);
Telescope::hideRequestHeaders([
'cookie',
'x-csrf-token',
'x-xsrf-token',
]);
}
/**
* Register the Telescope gate.
*
* This gate determines who can access Telescope in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewTelescope', function ($user) {
$root_user = User::find(0);
return in_array($user->email, [
$root_user->email,
]);
});
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Support;
/**
* Provider-agnostic DNS records to point hostnames at a Coolify server.
*/
class DnsRecordHints
{
/**
* Build A/AAAA entries for every hostname (deduped).
*
* @param array<int, string|null> $hostnames
* @return array<int, array{type: string, name: string, value: string}>
*/
public static function forHostnames(array $hostnames, ?string $ipv4, ?string $ipv6 = null): array
{
$records = [];
$seen = [];
foreach ($hostnames as $hostname) {
if (! is_string($hostname) || trim($hostname) === '') {
continue;
}
foreach (self::forTarget($hostname, $ipv4, $ipv6) as $record) {
$key = strtolower($record['type'].'|'.$record['name'].'|'.$record['value']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$records[] = $record;
}
}
usort($records, function (array $a, array $b): int {
return [$a['name'], $a['type'], $a['value']] <=> [$b['name'], $b['type'], $b['value']];
});
return $records;
}
/**
* @return array<int, array{type: string, name: string, value: string}>
*/
public static function forTarget(?string $hostname, ?string $ipv4, ?string $ipv6 = null): array
{
$records = [];
$fqdn = self::normalizeHostname($hostname);
if ($fqdn === null) {
return [];
}
if (filled($ipv4) && filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$records[] = [
'type' => 'A',
'name' => $fqdn,
'value' => $ipv4,
];
}
if (filled($ipv6) && filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$records[] = [
'type' => 'AAAA',
'name' => $fqdn,
'value' => $ipv6,
];
}
return $records;
}
public static function normalizeHostname(?string $hostname): ?string
{
if (blank($hostname)) {
return null;
}
$hostname = strtolower(trim($hostname));
$hostname = preg_replace('#^https?://#i', '', $hostname) ?? $hostname;
$hostname = explode('/', $hostname)[0] ?? $hostname;
$hostname = explode(':', $hostname)[0] ?? $hostname;
$hostname = rtrim($hostname, '.');
if ($hostname === '' || $hostname === '@' || ! str_contains($hostname, '.')) {
return null;
}
// Strip path-like noise; host only.
if (filter_var($hostname, FILTER_VALIDATE_IP)) {
return null;
}
return $hostname;
}
/**
* Relative name for a zone (e.g. app for app.example.com, @ for example.com).
*/
public static function relativeName(?string $hostname): string
{
$fqdn = self::normalizeHostname($hostname);
if ($fqdn === null) {
return '@';
}
$labels = array_values(array_filter(explode('.', $fqdn), fn (string $p) => $p !== ''));
if (count($labels) <= 2) {
return '@';
}
return implode('.', array_slice($labels, 0, -2));
}
/**
* Plain-text block suitable for clipboard (type / name / value).
*
* @param array<int, array{type: string, name: string, value: string}> $records
*/
public static function toCopyText(array $records): string
{
if ($records === []) {
return '';
}
$lines = ["Type\tName\tValue"];
foreach ($records as $record) {
$lines[] = "{$record['type']}\t{$record['name']}\t{$record['value']}";
}
return implode("\n", $lines);
}
}
@@ -0,0 +1,338 @@
<?php
namespace App\Support\DomainConnect;
use InvalidArgumentException;
use RuntimeException;
/**
* Builds Cloudflare Domain Connect synchronous "apply template" URLs.
*
* @see https://developers.cloudflare.com/dns/reference/domain-connect/
* @see https://github.com/Domain-Connect/spec/blob/master/Domain%20Connect%20Spec%20Draft.adoc
*/
class CloudflareDomainConnect
{
public const CLOUDFLARE_SYNC_UX = 'https://dash.cloudflare.com/domainconnect';
public function isConfigured(): bool
{
return filled($this->privateKeyPem());
}
/**
* Domain Connect is a Coolify Cloud feature and requires a signing key
* (instance setting or env) to be present at runtime.
*/
public function isAvailable(): bool
{
return isCloud() && $this->isConfigured();
}
public function providerId(): string
{
return (string) config('services.domain_connect.provider_id', 'coolify.io');
}
public function serviceId(): string
{
return (string) config('services.domain_connect.service_id', 'hosting');
}
public function keyId(): string
{
return (string) config('services.domain_connect.key_id', '_dcpubkeyv1');
}
/**
* @param array<string, string|null> $variables Template variables (e.g. ip). Null values become empty strings.
*/
public function buildApplyUrl(
string $domain,
array $variables = [],
?string $host = null,
?string $redirectUri = null,
): string {
if (! $this->isAvailable()) {
throw new RuntimeException(
'Domain Connect is only available on Coolify Cloud when a Domain Connect private key is configured.'
);
}
$domain = strtolower(trim($domain));
$domain = preg_replace('#^https?://#', '', $domain) ?? $domain;
$domain = rtrim(explode('/', $domain)[0] ?? $domain, '.');
if ($domain === '' || ! str_contains($domain, '.')) {
throw new InvalidArgumentException('A valid domain is required for Cloudflare Domain Connect.');
}
$host = $host === null ? '' : strtolower(trim($host));
$host = rtrim($host, '.');
if ($host === '@') {
$host = '';
}
// Signature covers the query string excluding `key` and `sig` (Domain Connect spec).
$params = [
'domain' => $domain,
'host' => $host,
];
foreach ($variables as $name => $value) {
if ($name === 'domain' || $name === 'host' || $name === 'key' || $name === 'sig') {
continue;
}
$params[$name] = $value === null ? '' : (string) $value;
}
if (filled($redirectUri)) {
$params['redirect_uri'] = $redirectUri;
}
$queryToSign = $this->buildQueryString($params);
$signature = $this->sign($queryToSign);
// Cloudflare requires `sig` as the last query parameter; `key` is also required.
$finalQuery = $queryToSign
.'&key='.rawurlencode($this->keyId())
.'&sig='.rawurlencode($signature);
return self::CLOUDFLARE_SYNC_UX
.'/v2/domainTemplates/providers/'.rawurlencode($this->providerId())
.'/services/'.rawurlencode($this->serviceId())
.'/apply?'.$finalQuery;
}
/**
* Point a zone (and optional host) A record at the Coolify server IP.
*/
public function buildHostingApplyUrl(
string $domain,
string $ip,
?string $host = null,
?string $redirectUri = null,
): string {
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
throw new InvalidArgumentException('A valid IPv4 or IPv6 address is required for DNS autoconfigure.');
}
return $this->buildApplyUrl(
domain: $domain,
variables: ['ip' => $ip],
host: $host,
redirectUri: $redirectUri,
);
}
/**
* Split a hostname into its registrable domain and relative host.
*
* Uses a small multi-part public-suffix set for common cases (e.g. co.uk)
* instead of shipping the full Mozilla Public Suffix List (~300KB).
* Unknown multi-part TLDs fall back to last-two-labels; Cloudflare will
* still match the user's zone when the guess is close enough.
*
* @return array{domain: string, host: string}
*/
public static function splitHostname(string $hostname): array
{
$hostname = strtolower(trim($hostname));
$hostname = preg_replace('#^https?://#', '', $hostname) ?? $hostname;
$hostname = explode('/', $hostname)[0] ?? $hostname;
$hostname = explode(':', $hostname)[0] ?? $hostname;
$hostname = rtrim($hostname, '.');
$labels = array_values(array_filter(explode('.', $hostname), fn (string $p) => $p !== ''));
if (count($labels) < 2) {
throw new InvalidArgumentException('Hostname must include a domain (e.g. example.com or app.example.com).');
}
$suffixLabelCount = self::publicSuffixLabelCount($labels);
$registrableLabelCount = $suffixLabelCount + 1;
if (count($labels) < $registrableLabelCount) {
throw new InvalidArgumentException('Hostname must include a registrable domain.');
}
$domain = implode('.', array_slice($labels, -$registrableLabelCount));
$hostLabels = array_slice($labels, 0, -$registrableLabelCount);
$host = implode('.', $hostLabels);
return ['domain' => $domain, 'host' => $host];
}
/**
* How many trailing labels form the public suffix (effective TLD).
*
* @param list<string> $labels
*/
protected static function publicSuffixLabelCount(array $labels): int
{
$count = count($labels);
// Longest multi-part match first (up to 3 labels: e.g. com.au, co.uk).
for ($length = min(3, $count - 1); $length >= 2; $length--) {
$candidate = implode('.', array_slice($labels, -$length));
if (isset(self::MULTI_PART_PUBLIC_SUFFIXES[$candidate])) {
return $length;
}
}
return 1;
}
/**
* Common multi-label public suffixes. Keys only; values unused.
* Not exhaustive — covers frequent Domain Connect / Cloudflare zones.
*
* @var array<string, true>
*/
private const MULTI_PART_PUBLIC_SUFFIXES = [
// United Kingdom / related
'ac.uk' => true,
'co.uk' => true,
'gov.uk' => true,
'ltd.uk' => true,
'me.uk' => true,
'net.uk' => true,
'org.uk' => true,
'plc.uk' => true,
'sch.uk' => true,
// Australia
'com.au' => true,
'net.au' => true,
'org.au' => true,
'edu.au' => true,
'gov.au' => true,
'asn.au' => true,
'id.au' => true,
// New Zealand
'co.nz' => true,
'net.nz' => true,
'org.nz' => true,
'govt.nz' => true,
'ac.nz' => true,
// Japan
'co.jp' => true,
'or.jp' => true,
'ne.jp' => true,
'ac.jp' => true,
'go.jp' => true,
// Brazil
'com.br' => true,
'net.br' => true,
'org.br' => true,
'gov.br' => true,
// India
'co.in' => true,
'net.in' => true,
'org.in' => true,
'gen.in' => true,
'firm.in' => true,
'ind.in' => true,
// South Africa
'co.za' => true,
'org.za' => true,
'web.za' => true,
'net.za' => true,
// Mexico / LatAm
'com.mx' => true,
'org.mx' => true,
'gob.mx' => true,
'com.ar' => true,
'com.co' => true,
'com.pe' => true,
'com.cl' => true,
// Asia / others
'com.cn' => true,
'net.cn' => true,
'org.cn' => true,
'com.hk' => true,
'com.sg' => true,
'com.tw' => true,
'com.my' => true,
'com.ph' => true,
'com.tr' => true,
'com.ua' => true,
'com.pl' => true,
'com.ru' => true,
'co.kr' => true,
'co.il' => true,
'com.sa' => true,
'com.eg' => true,
'com.ng' => true,
// EU-style
'co.at' => true,
'or.at' => true,
'co.nl' => true,
'com.de' => true,
// Platforms sometimes used as zones
'github.io' => true,
'pages.dev' => true,
];
/**
* @param array<string, string> $params
*/
protected function buildQueryString(array $params): string
{
$parts = [];
foreach ($params as $key => $value) {
// Always include empty values (Resend sends host= for apex).
$parts[] = rawurlencode((string) $key).'='.rawurlencode($value);
}
return implode('&', $parts);
}
protected function sign(string $queryString): string
{
$privateKey = openssl_pkey_get_private($this->privateKeyPem());
if ($privateKey === false) {
throw new RuntimeException('Invalid Domain Connect private key.');
}
$signature = '';
$ok = openssl_sign($queryString, $signature, $privateKey, OPENSSL_ALGO_SHA256);
if (! $ok) {
throw new RuntimeException('Failed to sign Domain Connect apply URL.');
}
return base64_encode($signature);
}
protected function privateKeyPem(): ?string
{
$key = null;
try {
$settingsKey = data_get(instanceSettings(), 'domain_connect_private_key');
if (is_string($settingsKey) && trim($settingsKey) !== '') {
$key = $settingsKey;
}
} catch (\Throwable) {
// Instance settings may be unavailable during early boot/tests.
}
if ($key === null) {
$envKey = config('services.domain_connect.private_key');
if (is_string($envKey) && trim($envKey) !== '') {
$key = $envKey;
}
}
if ($key === null) {
return null;
}
$key = str_replace(["\r\n", "\r"], "\n", $key);
// Allow single-line env values with literal \n
if (! str_contains($key, "\n") && str_contains($key, '\\n')) {
$key = str_replace('\\n', "\n", $key);
}
return $key;
}
}
+1 -5
View File
@@ -27,7 +27,7 @@ class Checkbox extends Component
public string|bool $instantSave = false,
public bool $live = false,
public bool $disabled = false,
public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base',
public string $defaultClass = 'peer absolute inset-0 z-10 m-0 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed',
public ?string $canGate = null,
public mixed $canResource = null,
public bool $autoDisable = true,
@@ -41,10 +41,6 @@ class Checkbox extends Component
$this->instantSave = false; // Disable instant save for unauthorized users
}
}
if ($this->disabled) {
$this->defaultClass .= ' opacity-40';
}
}
/**
+4 -2
View File
@@ -73,8 +73,10 @@ class EnvVarInput extends Component
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
if ($this->type === 'password') {
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
// Durable class (not type-attr based): Alpine may toggle type to "text" when revealing,
// and settings-workspace CSS otherwise overrides utility padding-right.
if ($this->type === 'password' && $this->allowToPeak) {
$this->defaultClass = $this->defaultClass.' input-with-password-toggle';
}
$this->scopeUrls = [
+4 -2
View File
@@ -68,8 +68,10 @@ class Input extends Component
if (is_null($this->name)) {
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
if ($this->type === 'password') {
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
// Durable class (not type-attr based): Alpine may toggle type to "text" when revealing,
// and settings-workspace CSS otherwise overrides utility padding-right.
if ($this->type === 'password' && $this->allowToPeak) {
$this->defaultClass = $this->defaultClass.' input-with-password-toggle';
}
// $this->label = Str::title($this->label);