mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-19 18:15:31 +00:00
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:
1 parent
0b843bb07c
commit
8ae56587d5
497 files changed
+44045
-22630
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,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,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,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,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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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".');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user