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

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

No files matched your search

@@ -2,7 +2,9 @@
namespace App\Livewire\Project\Application\Deployment;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -28,6 +30,18 @@ class Index extends Component
public ?string $pull_request_id = null;
public array $pullRequestOptions = [];
public string $search = '';
public string $deploymentFilter = 'all';
public string $deploymentSort = 'newest';
public array $statusFilterOptions = [];
public array $sourceFilterOptions = [];
protected $queryString = ['pull_request_id'];
public function getListeners()
@@ -64,8 +78,16 @@ class Index extends Component
}
}
['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->defaultTake, $this->pull_request_id);
$this->application = $application;
$this->loadPullRequestOptions();
$this->loadDeploymentFilterOptions();
['deployments' => $deployments, 'count' => $count] = $application->deployments(
search: $this->search,
filter: $this->deploymentFilter,
sort: $this->deploymentSort,
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
);
$this->deployments = $deployments;
$this->deployments_count = $count;
$this->current_url = url()->current();
@@ -114,14 +136,75 @@ class Index extends Component
$this->loadDeployments();
}
public function goToPage(int $page): void
{
$lastPage = max(1, (int) ceil($this->deployments_count / $this->defaultTake));
$page = max(1, min($page, $lastPage));
$this->skip = ($page - 1) * $this->defaultTake;
$this->showPrev = $page > 1;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function loadDeployments()
{
['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->defaultTake, $this->pull_request_id);
['deployments' => $deployments, 'count' => $count] = $this->application->deployments(
skip: $this->skip,
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
search: $this->search,
filter: $this->deploymentFilter,
sort: $this->deploymentSort,
);
$this->deployments = $deployments;
$this->deployments_count = $count;
$this->showMore();
}
public function updatedSearch(): void
{
$this->resetPaginationAndLoad();
}
public function setDeploymentFilter(string $filter): void
{
$validFilters = collect($this->statusFilterOptions)
->concat($this->sourceFilterOptions)
->pluck('value')
->push('all');
if (! $validFilters->contains($filter)) {
return;
}
$this->deploymentFilter = $filter;
$this->pull_request_id = null;
$this->resetPaginationAndLoad();
}
public function setPullRequestFilter(string $pullRequestId): void
{
$validPullRequestIds = collect($this->pullRequestOptions)->pluck('value');
if (! $validPullRequestIds->contains($pullRequestId)) {
return;
}
$this->pull_request_id = $pullRequestId === '' ? null : $pullRequestId;
$this->deploymentFilter = 'all';
$this->resetPaginationAndLoad();
}
public function setDeploymentSort(string $sort): void
{
if (! in_array($sort, ['newest', 'oldest'], true)) {
return;
}
$this->deploymentSort = $sort;
$this->resetPaginationAndLoad();
}
public function updatedPullRequestId($value)
{
// Sanitize and validate the pull request ID
@@ -139,20 +222,15 @@ class Index extends Component
$this->pull_request_id = null;
}
// Reset pagination when filter changes
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
$this->deploymentFilter = 'all';
$this->resetPaginationAndLoad();
}
public function clearFilter()
{
$this->pull_request_id = null;
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
$this->deploymentFilter = 'all';
$this->resetPaginationAndLoad();
}
private function updateCurrentPage()
@@ -160,6 +238,95 @@ class Index extends Component
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
}
private function loadPullRequestOptions(): void
{
$pullRequestIds = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->where('pull_request_id', '>', 0)
->distinct()
->orderByDesc('pull_request_id')
->pluck('pull_request_id')
->map(fn ($pullRequestId) => (string) $pullRequestId)
->values();
if ($this->pull_request_id && ! $pullRequestIds->contains($this->pull_request_id)) {
$this->pull_request_id = null;
}
$this->pullRequestOptions = collect([
['value' => '', 'label' => 'All deployments'],
])
->concat($pullRequestIds->map(fn ($pullRequestId) => [
'value' => $pullRequestId,
'label' => "Pull request #{$pullRequestId}",
]))
->all();
}
private function loadDeploymentFilterOptions(): void
{
$statuses = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->distinct()
->pluck('status');
$statusLabels = [
ApplicationDeploymentStatus::FINISHED->value => 'Success',
ApplicationDeploymentStatus::FAILED->value => 'Failed',
ApplicationDeploymentStatus::IN_PROGRESS->value => 'In progress',
ApplicationDeploymentStatus::QUEUED->value => 'Queued',
ApplicationDeploymentStatus::CANCELLED_BY_USER->value => 'Cancelled',
];
$this->statusFilterOptions = collect($statusLabels)
->filter(fn (string $label, string $status) => $statuses->contains($status))
->map(fn (string $label, string $status) => [
'value' => "status:{$status}",
'label' => $label,
])
->values()
->all();
$sourceKeys = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->selectRaw("
CASE
WHEN pull_request_id > 0 THEN 'pull-request'
WHEN is_webhook THEN 'webhook'
WHEN rollback THEN 'rollback'
WHEN is_api THEN 'api'
ELSE 'manual'
END AS source_key
")
->distinct()
->pluck('source_key');
$sourceLabels = [
'manual' => 'Manual',
'pull-request' => 'Pull requests',
'webhook' => 'Webhooks',
'api' => 'API',
'rollback' => 'Rollbacks',
];
$this->sourceFilterOptions = collect($sourceLabels)
->filter(fn (string $label, string $source) => $sourceKeys->contains($source))
->map(fn (string $label, string $source) => [
'value' => "source:{$source}",
'label' => $label,
])
->values()
->all();
}
private function resetPaginationAndLoad(): void
{
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function render()
{
return view('livewire.project.application.deployment.index');
File diff suppressed because it is too large. Load diff
+23 -3
View File
@@ -94,6 +94,9 @@ class General extends Component
public bool $isSpa = false;
/** UI-only aggregate of isStatic/isSpa: dynamic | static | spa */
public string $siteType = 'dynamic';
public bool $isBuildServerEnabled = false;
public bool $isPreserveRepositoryEnabled = false;
@@ -437,6 +440,7 @@ class General extends Component
// Application settings properties
$this->isStatic = $this->application->settings->is_static;
$this->isSpa = $this->application->settings->is_spa;
$this->siteType = $this->isStatic ? ($this->isSpa ? 'spa' : 'static') : 'dynamic';
$this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
$this->isPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
$this->isContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
@@ -444,6 +448,13 @@ class General extends Component
}
}
public function setSiteType(): void
{
$this->isStatic = $this->siteType !== 'dynamic';
$this->isSpa = $this->siteType === 'spa';
$this->instantSave();
}
public function instantSave()
{
try {
@@ -680,7 +691,10 @@ class General extends Component
if ($this->application->additional_servers->count() === 0) {
foreach ($domains as $domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
}
}
}
@@ -722,7 +736,10 @@ class General extends Component
$this->application->redirect = $this->redirect;
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
if ($has_www === 0 && $this->application->redirect === 'www') {
$this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list and as an A DNS record (if applicable).');
$server = $this->application->destination?->server;
$target = $server ? (serverDnsTargetIp($server) ?? $server->ip) : null;
$dnsHint = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', "You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list ({$dnsHint}).");
return;
}
@@ -847,7 +864,10 @@ class General extends Component
$domain = data_get($service, 'domain');
if ($domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
}
}
}
@@ -20,6 +20,8 @@ class Heading extends Component
public array $parameters;
public string $activeRouteName = '';
protected string $deploymentUuid;
public bool $docker_cleanup = true;
@@ -38,6 +40,7 @@ class Heading extends Component
public function mount()
{
$this->activeRouteName = request()->route()?->getName() ?? '';
$this->parameters = [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
@@ -129,7 +129,10 @@ class Previews extends Component
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
$this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$fqdn->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$success = false;
}
+10 -2
View File
@@ -252,9 +252,17 @@ class BackupEdit extends Component
]);
}
// Instance databases (e.g. coolify-db) have no project/environment.
// Stay on the current page (settings.backup) instead of redirecting.
$project = $database->project();
$environment = $database->environment;
if (! $project || ! $environment) {
return null;
}
return redirect()->route('project.database.backup.executions', [
'project_uuid' => $database->project()->uuid,
'environment_uuid' => $database->environment->uuid,
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $this->backup->uuid,
]);
+55 -12
View File
@@ -2,28 +2,71 @@
namespace App\Livewire\Project;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Index extends Component
{
public $projects;
public $servers;
public $private_keys;
public function mount()
public function mount(): void
{
$this->private_keys = PrivateKey::ownedByCurrentTeamCached();
$this->projects = Project::ownedByCurrentTeamCached();
$this->servers = Server::ownedByCurrentTeamCached();
// Only load what the page renders. Servers/private keys were previously
// hydrated into public Livewire state but never used by the view.
$this->projects = Project::ownedByCurrentTeam()
->with(['environments:id,uuid,name,project_id'])
->withCount([
'applications',
'services',
'postgresqls',
'redis',
'keydbs',
'dragonflies',
'clickhouses',
'mongodbs',
'mysqls',
'mariadbs',
])
->get();
}
public function render()
public function render(): View
{
return view('livewire.project.index');
return view('livewire.project.index', [
'projectsJs' => $this->projects->map(function (Project $project): array {
$firstEnvironment = $project->environments->first();
$resourceCount = collect([
$project->applications_count,
$project->services_count,
$project->postgresqls_count,
$project->redis_count,
$project->keydbs_count,
$project->dragonflies_count,
$project->clickhouses_count,
$project->mongodbs_count,
$project->mysqls_count,
$project->mariadbs_count,
])->sum();
return [
'uuid' => $project->uuid,
'name' => $project->name,
'description' => $project->description,
'href' => $project->navigateTo(),
'environmentCount' => $project->environments->count(),
'resourceCount' => $resourceCount,
'settingsHref' => auth()->user()->can('update', $project)
? route('project.edit', ['project_uuid' => $project->uuid])
: null,
'addResourceHref' => $firstEnvironment && auth()->user()->can('createAnyResource')
? route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
])
: null,
];
})->values()->toArray(),
]);
}
}
+19 -6
View File
@@ -161,13 +161,15 @@ class Select extends Component
[
'id' => 'public',
'name' => 'Public Repository',
'description' => 'You can deploy any kind of public repositories from the supported git providers.',
'description' => 'Connect any public Git repository and let Coolify build and deploy it from source.',
'documentation' => 'https://coolify.io/docs/applications/ci-cd',
'logo' => asset('svgs/git.svg'),
],
[
'id' => 'private-gh-app',
'name' => 'Private Repository (with GitHub App)',
'description' => 'You can deploy public & private repositories through your GitHub Apps.',
'description' => 'Deploy a private GitHub repository with automatic webhooks and pull request support.',
'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/setup-app',
'logo' => asset('svgs/github.svg'),
],
[
@@ -179,7 +181,8 @@ class Select extends Component
[
'id' => 'private-deploy-key',
'name' => 'Private Repository (with Deploy Key)',
'description' => 'You can deploy private repositories with a deploy key.',
'description' => 'Connect a private Git repository over SSH using a repository-scoped deploy key.',
'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/deploy-key',
'logo' => asset('svgs/git.svg'),
],
];
@@ -187,19 +190,22 @@ class Select extends Component
[
'id' => 'dockerfile',
'name' => 'Dockerfile',
'description' => 'You can deploy a simple Dockerfile, without Git.',
'description' => 'Build and deploy an application from a Dockerfile without connecting a Git repository.',
'documentation' => 'https://coolify.io/docs/applications/build-packs/dockerfile',
'logo' => asset('svgs/docker.svg'),
],
[
'id' => 'docker-compose-empty',
'name' => 'Docker Compose Empty',
'description' => 'You can deploy complex application easily with Docker Compose, without Git.',
'description' => 'Create a multi-container application and provide the Docker Compose definition manually.',
'documentation' => 'https://coolify.io/docs/applications/build-packs/docker-compose',
'logo' => asset('svgs/docker.svg'),
],
[
'id' => 'docker-image',
'name' => 'Docker Image',
'description' => 'You can deploy an existing Docker Image from any Registry, without Git.',
'description' => 'Run a prebuilt image from Docker Hub or another compatible container registry.',
'documentation' => 'https://coolify.io/docs/applications',
'logo' => asset('svgs/docker.svg'),
],
];
@@ -286,6 +292,13 @@ class Select extends Component
private function serviceTemplatesLastUpdated(): ?string
{
$fetchedAt = get_service_templates_fetched_at();
if ($fetchedAt instanceof CarbonImmutable) {
return $fetchedAt
->timezone(config('app.timezone'))
->format('M j, Y H:i');
}
return $this->formatLastModified($this->serviceTemplatesPath());
}
+13 -11
View File
@@ -188,24 +188,26 @@ class Index extends Component
'dragonflies' => $this->dragonflies,
'clickhouses' => $this->clickhouses,
'services' => $this->services,
'applicationsJs' => $this->toSearchableArray($this->applications),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls),
'redisJs' => $this->toSearchableArray($this->redis),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs),
'mysqlsJs' => $this->toSearchableArray($this->mysqls),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs),
'keydbsJs' => $this->toSearchableArray($this->keydbs),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses),
'servicesJs' => $this->toSearchableArray($this->services),
'applicationsJs' => $this->toSearchableArray($this->applications, 'application', 'Application'),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'PostgreSQL'),
'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Redis'),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'MongoDB'),
'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'MySQL'),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'MariaDB'),
'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'KeyDB'),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Dragonfly'),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'ClickHouse'),
'servicesJs' => $this->toSearchableArray($this->services, 'service', 'Service'),
]);
}
private function toSearchableArray(Collection $items): array
private function toSearchableArray(Collection $items, string $type, string $typeLabel): array
{
return $items->map(fn ($item) => [
'uuid' => $item->uuid,
'name' => $item->name,
'type' => $type,
'typeLabel' => $item instanceof V5Application ? 'Application (V5)' : $typeLabel,
'fqdn' => $item->fqdn ?? null,
'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null),
'status' => $item->status ?? '',
File diff suppressed because it is too large. Load diff
+12
View File
@@ -282,6 +282,18 @@ class Index extends Component
}
}
public function enablePublicAccess(): void
{
$this->isPublic = true;
$this->instantSave();
}
public function disablePublicAccess(): void
{
$this->isPublic = false;
$this->instantSave();
}
public function instantSave()
{
try {
@@ -6,6 +6,7 @@ use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@@ -29,10 +30,22 @@ class All extends Component
public string $search = '';
public string $environmentFilter = 'all';
public int $page = 1;
public int $perPage = 10;
public bool $is_env_sorting_enabled = false;
public bool $use_build_secrets = false;
/**
* Environment variable rows are loaded after first paint via wire:init
* so the surrounding configuration page can render immediately.
*/
public bool $readyToLoad = false;
protected $listeners = [
'saveKey' => 'submit',
'refreshEnvs',
@@ -41,6 +54,7 @@ class All extends Component
public function updatedSearch(): void
{
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
@@ -51,6 +65,11 @@ class All extends Component
unset($this->hardcodedEnvironmentVariables);
unset($this->hardcodedEnvironmentVariablesPreview);
unset($this->hasEnvironmentVariables);
unset($this->environmentVariableRows);
unset($this->environmentVariablePageRows);
unset($this->environmentVariableRowCount);
unset($this->environmentVariableLastPage);
unset($this->currentEnvironmentVariablePage);
}
public function mount()
@@ -63,7 +82,25 @@ class All extends Component
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
}
$this->getDevView();
// Intentionally skip loading env vars / developer-view bulk text here.
// loadEnvironmentVariables() is triggered from the frontend via wire:init.
}
/**
* Frontend-initiated load of environment variables after the page shell paints.
*/
public function loadEnvironmentVariables(): void
{
if ($this->readyToLoad) {
return;
}
$this->readyToLoad = true;
$this->clearEnvironmentVariableCaches();
if ($this->view === 'dev') {
$this->getDevView();
}
}
public function instantSave()
@@ -71,10 +108,14 @@ class All extends Component
try {
$this->authorize('manageEnvironment', $this->resource);
$this->page = 1;
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
$this->resource->settings->use_build_secrets = $this->use_build_secrets;
$this->resource->settings->save();
$this->getDevView();
$this->clearEnvironmentVariableCaches();
if ($this->readyToLoad && $this->view === 'dev') {
$this->getDevView();
}
$this->dispatch('success', 'Environment variable settings updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
@@ -84,20 +125,30 @@ class All extends Component
public function getEnvironmentVariablesProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getEnvironmentVariables(false);
}
public function getEnvironmentVariablesPreviewProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getEnvironmentVariables(true);
}
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true, bool $withValue = true): Collection
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
// Full-value loads (dev view / tests) still use relationship queries.
// List/table pagination uses fetchManagedEnvironmentVariables() without values.
$query = $isPreview
? $this->resource->environment_variables_preview()
: $this->resource->environment_variables();
@@ -116,7 +167,15 @@ class All extends Component
$query->orderBy('order');
}
return $this->nullLockedValues($query->get());
if (! $withValue) {
$query->select(self::LIST_COLUMNS);
}
$variables = $query->get()->each(fn (EnvironmentVariable $environmentVariable) => $environmentVariable->setAppends([]));
return $withValue
? $this->nullLockedValues($variables)
: $variables;
}
private function searchTerm(): string
@@ -131,14 +190,11 @@ class All extends Component
public function getHasEnvironmentVariablesProperty(): bool
{
$hasPreviewEnvironmentVariables = $this->supportsPreviewEnvironmentVariables() && (
$this->environmentVariablesPreview->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty()
);
if (! $this->readyToLoad) {
return false;
}
return $this->environmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariables->isNotEmpty() ||
$hasPreviewEnvironmentVariables;
return $this->environmentVariableRowCount > 0;
}
private function nullLockedValues($envs)
@@ -162,14 +218,341 @@ class All extends Component
public function getHardcodedEnvironmentVariablesProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getHardcodedVariables(false);
}
public function getHardcodedEnvironmentVariablesPreviewProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getHardcodedVariables(true);
}
/**
* Full in-memory row list. Prefer page/count helpers for UI rendering so large
* variable sets stay cheap; this remains for tests and non-paginated callers.
*/
public function getEnvironmentVariableRowsProperty(): Collection
{
if (! $this->readyToLoad) {
return collect();
}
$rows = collect();
foreach ($this->environmentVariableSegments() as $segment) {
if ($segment['kind'] === 'managed') {
foreach ($this->fetchManagedEnvironmentVariables($segment['is_preview'], null, null) as $environmentVariable) {
$rows->push($this->managedEnvironmentVariableRow($environmentVariable));
}
continue;
}
$hardcoded = $segment['is_preview']
? $this->hardcodedEnvironmentVariablesPreview
: $this->hardcodedEnvironmentVariables;
foreach ($hardcoded->values() as $index => $environmentVariable) {
$rows->push($this->hardcodedEnvironmentVariableRow(
$environmentVariable,
$segment['is_preview'],
$index,
));
}
}
return $rows->values();
}
public function getEnvironmentVariablePageRowsProperty(): Collection
{
if (! $this->readyToLoad) {
return collect();
}
$page = $this->currentEnvironmentVariablePage;
$offset = max(0, ($page - 1) * $this->perPage);
$remaining = $this->perPage;
$rows = collect();
foreach ($this->environmentVariableSegments() as $segment) {
$segmentCount = $segment['count'];
if ($segmentCount === 0) {
continue;
}
if ($offset >= $segmentCount) {
$offset -= $segmentCount;
continue;
}
$take = min($remaining, $segmentCount - $offset);
if ($segment['kind'] === 'managed') {
$variables = $this->fetchManagedEnvironmentVariables($segment['is_preview'], $offset, $take);
foreach ($variables as $environmentVariable) {
$rows->push($this->managedEnvironmentVariableRow($environmentVariable));
}
} else {
$hardcoded = $segment['is_preview']
? $this->hardcodedEnvironmentVariablesPreview
: $this->hardcodedEnvironmentVariables;
foreach ($hardcoded->slice($offset, $take)->values() as $index => $environmentVariable) {
$rows->push($this->hardcodedEnvironmentVariableRow(
$environmentVariable,
$segment['is_preview'],
$offset + $index,
));
}
}
$remaining -= $take;
$offset = 0;
if ($remaining <= 0) {
break;
}
}
return $rows->values();
}
public function getEnvironmentVariableRowCountProperty(): int
{
if (! $this->readyToLoad) {
return 0;
}
return collect($this->environmentVariableSegments())->sum('count');
}
public function getEnvironmentVariableLastPageProperty(): int
{
return max(1, (int) ceil($this->environmentVariableRowCount / $this->perPage));
}
public function getCurrentEnvironmentVariablePageProperty(): int
{
return min($this->page, $this->environmentVariableLastPage);
}
public function setEnvironmentFilter(string $filter): void
{
if (! in_array($filter, ['all', 'production', 'preview'], true)) {
return;
}
$this->environmentFilter = $filter;
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function setEnvironmentVariablePage(int $page): void
{
$this->page = max(1, min($page, $this->environmentVariableLastPage));
$this->clearEnvironmentVariableCaches();
}
public function previousEnvironmentVariablePage(): void
{
$this->setEnvironmentVariablePage($this->currentEnvironmentVariablePage - 1);
}
public function nextEnvironmentVariablePage(): void
{
$this->setEnvironmentVariablePage($this->currentEnvironmentVariablePage + 1);
}
/**
* Ordered segments used for pagination: production managed → production hardcoded
* → preview managed → preview hardcoded (matching the historical table order).
*
* @return list<array{kind: string, is_preview: bool, count: int}>
*/
private function environmentVariableSegments(): array
{
$includeProduction = $this->environmentFilter === 'all' || $this->environmentFilter === 'production';
$includePreview = $this->supportsPreviewEnvironmentVariables()
&& ($this->environmentFilter === 'all' || $this->environmentFilter === 'preview');
$segments = [];
if ($includeProduction) {
$segments[] = [
'kind' => 'managed',
'is_preview' => false,
'count' => $this->countManagedEnvironmentVariables(false),
];
if ($this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => false,
'count' => $this->hardcodedEnvironmentVariables->count(),
];
}
}
if ($includePreview) {
$segments[] = [
'kind' => 'managed',
'is_preview' => true,
'count' => $this->countManagedEnvironmentVariables(true),
];
if ($this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => true,
'count' => $this->hardcodedEnvironmentVariablesPreview->count(),
];
}
}
return $segments;
}
private function managedEnvironmentVariablesQuery(bool $isPreview): Builder
{
$query = EnvironmentVariable::query()
->where('resourceable_type', $this->resource->getMorphClass())
->where('resourceable_id', $this->resource->id)
->where('is_preview', $isPreview);
$query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($this->searchTerm() !== '') {
$escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\');
$query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']);
}
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
} else {
$query->orderBy('order')->orderBy('id');
}
return $query;
}
private function countManagedEnvironmentVariables(bool $isPreview): int
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return 0;
}
return $this->managedEnvironmentVariablesQuery($isPreview)->count();
}
/**
* Columns needed to render the table row / nested Show component without decrypting secrets.
* The encrypted `value` column is intentionally omitted until edit/dev view loads it.
*
* @var list<string>
*/
private const LIST_COLUMNS = [
'id',
'uuid',
'key',
'comment',
'order',
'is_preview',
'is_multiline',
'is_literal',
'is_runtime',
'is_buildtime',
'is_required',
'is_shown_once',
'is_shared',
'resourceable_type',
'resourceable_id',
'created_at',
'updated_at',
'version',
];
private function fetchManagedEnvironmentVariables(?bool $isPreview, ?int $offset, ?int $limit, bool $withValue = false): Collection
{
if ($isPreview === true && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
if ($isPreview === null) {
$variables = collect();
if ($this->environmentFilter === 'all' || $this->environmentFilter === 'production') {
$variables = $variables->concat($this->fetchManagedEnvironmentVariables(false, null, null, $withValue));
}
if ($this->supportsPreviewEnvironmentVariables()
&& ($this->environmentFilter === 'all' || $this->environmentFilter === 'preview')) {
$variables = $variables->concat($this->fetchManagedEnvironmentVariables(true, null, null, $withValue));
}
return $variables->values();
}
$query = $this->managedEnvironmentVariablesQuery($isPreview);
if (! $withValue) {
$query->select(self::LIST_COLUMNS);
}
if ($offset !== null) {
$query->skip($offset);
}
if ($limit !== null) {
$query->take($limit);
}
$variables = $query->get()->each(function (EnvironmentVariable $environmentVariable) {
// Prevent accidental real_value / is_shared accessor work during Livewire hydration.
$environmentVariable->setAppends([]);
});
return $withValue
? $this->nullLockedValues($variables)
: $variables;
}
private function managedEnvironmentVariableRow(EnvironmentVariable $environmentVariable): array
{
return [
'id' => 'environment-'.$environmentVariable->id,
'kind' => 'managed',
'scope' => $environmentVariable->is_preview ? 'preview' : 'production',
'environmentVariable' => $environmentVariable,
];
}
private function hardcodedEnvironmentVariableRow(array $environmentVariable, bool $isPreview, int $index): array
{
$scope = $isPreview ? 'preview' : 'production';
return [
'id' => 'hardcoded-'.$scope.'-'.$environmentVariable['key'].'-'.($environmentVariable['service_name'] ?? 'default').'-'.$index,
'kind' => 'hardcoded',
'scope' => $scope,
'environmentVariable' => $environmentVariable,
];
}
private function showsHardcodedEnvironmentVariables(): bool
{
return $this->resource->type() === 'service' || $this->resource?->build_pack === 'dockercompose';
}
protected function getHardcodedVariables(bool $isPreview)
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
@@ -256,13 +639,17 @@ class All extends Component
public function switch()
{
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
if ($this->view === 'dev') {
$this->ensureEnvironmentVariablesLoaded();
$this->getDevView();
}
}
public function submit($data = null)
{
try {
$this->authorize('manageEnvironment', $this->resource);
$this->ensureEnvironmentVariablesLoaded();
if ($data === null) {
$this->handleBulkSubmit();
} else {
@@ -270,7 +657,9 @@ class All extends Component
}
$this->updateOrder();
$this->getDevView();
if ($this->view === 'dev') {
$this->getDevView();
}
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
@@ -278,6 +667,13 @@ class All extends Component
}
}
private function ensureEnvironmentVariablesLoaded(): void
{
if (! $this->readyToLoad) {
$this->loadEnvironmentVariables();
}
}
private function updateOrder()
{
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
@@ -495,7 +891,10 @@ class All extends Component
public function refreshEnvs()
{
$this->resource->refresh();
$this->readyToLoad = true;
$this->clearEnvironmentVariableCaches();
$this->getDevView();
if ($this->view === 'dev') {
$this->getDevView();
}
}
}
@@ -35,6 +35,10 @@ class Show extends Component
public string $type;
public int $tableAlphabeticalOrder = 0;
public int $tableCreationOrder = 0;
public string $key;
public ?string $value = null;
@@ -63,6 +67,18 @@ class Show extends Component
public bool $isValueHidden = false;
/**
* Decrypted value / real_value are only needed in the edit modal (or after save).
* Keeping them unloaded for table rows avoids decrypting every visible env on each page change.
*/
public bool $valuesLoaded = false;
/**
* Entangled with the edit modal open state so the modal stays open across the
* async loadValues() re-render (open immediately, decrypt after).
*/
public bool $editorOpen = false;
public array $problematicVariables = [];
protected $listeners = [
@@ -116,10 +132,33 @@ class Show extends Component
if (! $this->env->exists || ! $this->env->fresh()) {
return;
}
$this->valuesLoaded = false;
$this->syncData();
$this->checkEnvs();
}
/**
* Decrypt and resolve values only when the edit modal is opened.
*/
public function loadValues(): void
{
if ($this->valuesLoaded) {
return;
}
// List queries omit the encrypted value column; refresh so edit has a full model.
if ($this->env->exists) {
$fresh = $this->env->fresh();
if ($fresh) {
$fresh->setAppends([]);
$this->env = $fresh;
}
}
$this->hydrateValueFields();
$this->valuesLoaded = true;
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
@@ -149,29 +188,55 @@ class Show extends Component
$this->env->is_literal = $this->is_literal;
$this->env->is_shown_once = $this->is_shown_once;
$this->env->save();
$this->valuesLoaded = true;
} else {
// Table metadata only — never decrypt here. Values load via loadValues().
$this->env->setAppends([]);
$this->key = $this->env->key;
$this->value = $this->env->value;
$this->comment = $this->env->comment;
$this->is_multiline = $this->env->is_multiline;
$this->is_literal = $this->env->is_literal;
$this->is_shown_once = $this->env->is_shown_once;
$this->is_runtime = $this->env->is_runtime ?? true;
$this->is_buildtime = $this->env->is_buildtime ?? true;
$this->is_required = $this->env->is_required ?? false;
$this->is_really_required = $this->env->is_really_required ?? false;
$this->is_shared = $this->env->is_shared ?? false;
$this->real_value = $this->env->real_value;
$this->is_multiline = (bool) $this->env->is_multiline;
$this->is_literal = (bool) $this->env->is_literal;
$this->is_shown_once = (bool) $this->env->is_shown_once;
$this->is_runtime = (bool) ($this->env->is_runtime ?? true);
$this->is_buildtime = (bool) ($this->env->is_buildtime ?? true);
$this->is_required = (bool) ($this->env->is_required ?? false);
// Use the stored column, not the value-based accessor (that decrypts).
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
$this->isValueHidden = auth()->user()?->isMember() ?? false;
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
if ($this->valuesLoaded) {
$this->hydrateValueFields();
} else {
$this->value = null;
$this->real_value = null;
// Required badge: without decrypting, show when flagged required.
// Exact empty-value state is refined when the edit modal opens.
$this->is_really_required = $this->is_required;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
}
}
private function hydrateValueFields(): void
{
$this->value = $this->env->value;
$this->is_shared = (bool) ($this->env->is_shared ?? false);
if ($this->is_shared) {
$this->real_value = $this->env->real_value;
$this->is_really_required = $this->is_required && blank($this->real_value);
} else {
$this->real_value = null;
$this->is_really_required = $this->is_required && blank($this->value);
}
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
$this->value = null;
$this->real_value = null;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
}
public function checkEnvs()
{
$this->isDisabled = false;
@@ -215,6 +280,7 @@ class Show extends Component
{
try {
$this->authorize('update', $this->env);
$this->loadValues();
if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) {
$oldValue = $this->env->getOriginal('value');
@@ -238,7 +304,23 @@ class Show extends Component
#[Computed]
public function availableSharedVariables(): array
{
// Shared across all Show row components in the same request (edit modals).
static $requestCache = [];
$team = currentTeam();
$cacheKey = implode('|', [
$team?->id ?? 'none',
data_get($this->parameters, 'project_uuid', ''),
data_get($this->parameters, 'environment_uuid', ''),
data_get($this->parameters, 'server_uuid', ''),
data_get($this->parameters, 'application_uuid', ''),
data_get($this->parameters, 'service_uuid', ''),
]);
if (array_key_exists($cacheKey, $requestCache)) {
return $requestCache[$cacheKey];
}
$result = [
'team' => [],
'project' => [],
@@ -248,7 +330,7 @@ class Show extends Component
// Early return if no team
if (! $team) {
return $result;
return $requestCache[$cacheKey] = $result;
}
// Check if user can view team variables
@@ -359,7 +441,7 @@ class Show extends Component
}
}
return $result;
return $requestCache[$cacheKey] = $result;
}
public function delete()
@@ -16,6 +16,8 @@ class ShowHardcoded extends Component
public ?string $serviceName = null;
public bool $isPreview = false;
public function mount()
{
$this->key = $this->env['key'];
+24 -7
View File
@@ -80,14 +80,31 @@ class Logs extends Component
]);
return $containers->toArray();
} else {
$containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true);
if ($containers && $containers->count() > 0) {
return $containers->sort()->toArray();
}
return [];
}
// Docker labels differ by resource type:
// applications → coolify.applicationId, services → coolify.serviceId, databases → coolify.databaseId
$containers = match (true) {
$this->resource instanceof Application => getCurrentApplicationContainerStatus(
$server,
$this->resource->id,
includePullrequests: true
),
$this->resource instanceof Service => getCurrentServiceContainerStatus(
$server,
$this->resource->id
),
default => getCurrentDatabaseContainerStatus(
$server,
$this->resource->id
),
};
if ($containers && $containers->count() > 0) {
return $containers->sort()->toArray();
}
return [];
} catch (\Exception $e) {
// Log error but don't fail the entire operation
+2
View File
@@ -17,6 +17,8 @@ class Terminal extends Component
public bool $isTerminalConnected = false;
public string $variant = 'default';
private function checkShellAvailability(Server $server, string $container): bool
{
$escapedContainer = escapeshellarg($container);
+1 -1
View File
@@ -62,7 +62,7 @@ class Webhooks extends Component
'manual_webhook_secret_bitbucket' => $this->bitbucketManualWebhookSecret,
'manual_webhook_secret_gitea' => $this->giteaManualWebhookSecret,
]);
$this->dispatch('success', 'Secret Saved.');
$this->dispatch('success', 'Webhook secrets saved.');
} catch (\Exception $e) {
return handleError($e, $this);
}
+63 -3
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project;
use App\Models\Environment;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -34,7 +35,25 @@ class Show extends Component
public function mount(string $project_uuid)
{
try {
$this->project = Project::where('team_id', currentTeam()->id)->where('uuid', $project_uuid)->firstOrFail();
$this->project = Project::where('team_id', currentTeam()->id)
->where('uuid', $project_uuid)
->with([
'environments' => fn ($query) => $query
->withCount([
'applications',
'services',
'postgresqls',
'redis',
'keydbs',
'dragonflies',
'clickhouses',
'mongodbs',
'mysqls',
'mariadbs',
])
->orderBy('created_at'),
])
->firstOrFail();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -68,8 +87,49 @@ class Show extends Component
]);
}
public function render()
public function render(): View
{
return view('livewire.project.show');
$canUpdateProject = auth()->user()->can('update', $this->project);
$canCreateResource = auth()->user()->can('createAnyResource');
return view('livewire.project.show', [
'environmentsJs' => $this->project->environments->map(function (Environment $environment) use ($canCreateResource, $canUpdateProject): array {
$resourceCount = collect([
$environment->applications_count,
$environment->services_count,
$environment->postgresqls_count,
$environment->redis_count,
$environment->keydbs_count,
$environment->dragonflies_count,
$environment->clickhouses_count,
$environment->mongodbs_count,
$environment->mysqls_count,
$environment->mariadbs_count,
])->sum();
return [
'uuid' => $environment->uuid,
'name' => $environment->name,
'description' => $environment->description,
'resourceCount' => $resourceCount,
'href' => route('project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
]),
'settingsHref' => $canUpdateProject
? route('project.environment.edit', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
])
: null,
'addResourceHref' => $canCreateResource
? route('project.resource.create', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
])
: null,
];
})->values()->toArray(),
]);
}
}