Merge remote-tracking branch 'origin/main' into pr/11528
This commit is contained in:
commit
0ae8539b80
322 changed files with 12132 additions and 1641 deletions
|
|
@ -53,3 +53,4 @@ DUSK_DRIVER_URL=http://selenium:4444
|
|||
BUNNY_API_KEY=
|
||||
# For asset uploads
|
||||
BUNNY_STORAGE_API_KEY=
|
||||
AVATAR_CDN_URL=
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ REDIS_PASSWORD=coolify
|
|||
PUSHER_APP_ID=coolify
|
||||
PUSHER_APP_KEY=coolify
|
||||
PUSHER_APP_SECRET=coolify
|
||||
AVATAR_CDN_URL=
|
||||
|
|
|
|||
4
.github/workflows/sync-main-to-next.yml
vendored
4
.github/workflows/sync-main-to-next.yml
vendored
|
|
@ -1,8 +1,8 @@
|
|||
name: Sync main to next
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
22
AGENTS.md
22
AGENTS.md
|
|
@ -148,6 +148,11 @@ ### Authorization
|
|||
- Custom gates: `createAnyResource`, `canAccessTerminal`
|
||||
- Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods
|
||||
- Multi-tenancy via Teams — team auto-initializes notification settings on creation
|
||||
- Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security.
|
||||
- Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team.
|
||||
- Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data.
|
||||
- Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team.
|
||||
- Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data.
|
||||
|
||||
### Event Broadcasting
|
||||
- Soketi WebSocket server for real-time updates (ports 6001-6002 in dev)
|
||||
|
|
@ -191,6 +196,23 @@ ### Laravel 10 Structure (NOT Laravel 11+ slim structure)
|
|||
- Exception handler: `app/Exceptions/Handler.php`
|
||||
- Service providers in `app/Providers/`
|
||||
|
||||
## Livewire conventions
|
||||
|
||||
### Dynamic lists and snapshot errors
|
||||
|
||||
When an add, delete, or conversion leaves controls unresponsive and the browser reports `Snapshot missing on Livewire component`, inspect both component keys and refresh events. Stable keys alone may not fix it.
|
||||
|
||||
- Give every Livewire component rendered in a loop a stable key based on the record ID, UUID, filename, or another immutable identity. Never include a collection count, `$loop->index`, or a reindexed array position in the key.
|
||||
- Pass the same stable identity to edit/delete actions. A keyed row can survive reordering while a `wire:ignore` or teleported Alpine modal keeps its original `submitAction`; an action such as `removeItem($index)` then targets a stale position after the first deletion. Resolve the current row server-side from an ID, UUID, or stable row hash instead.
|
||||
- Do not broadcast one refresh event to both a parent list component and children that the parent may insert, remove, or hide during the same operation. This can queue a child update after its snapshot has been removed from the DOM.
|
||||
- Split refresh responsibilities into targeted events. Refresh the parent for counts and tab visibility, and refresh an existing child list with a separate event. Use `$this->dispatch('event')->to(Component::class)` instead of a page-wide event when possible.
|
||||
- Before targeting a child list, confirm that it existed before the mutation, still exists afterward, and is on the active tab. A newly inserted child loads current data during `mount()` and does not need an immediate refresh. A removed or hidden child must not receive one.
|
||||
- A child that deletes itself should finish its own update, then target only the parent to refresh counts. The parent should not send a refresh back to that child when the list became empty.
|
||||
- Apply the same pattern to file, directory, conversion, and external reload paths such as Compose edits. One remaining broad event can reproduce the race.
|
||||
- Add regression tests that assert the scoped event names, assert the old broad event is not dispatched, and verify that keys do not depend on counts or positions. Manually repeat add/delete operations while watching the browser console.
|
||||
|
||||
The persistent-storage implementation is the reference pattern: `Project\Service\Storage` handles `storageCountsChanged`, while `Project\Shared\Storages\All` handles `refreshVolumeList`.
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- Use `php artisan make:*` commands with `--no-interaction` to create files
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ class StopApplication
|
|||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true)
|
||||
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainers = true): ?string
|
||||
{
|
||||
$containerPresent = ! $removeContainers;
|
||||
$servers = collect([$application->destination->server]);
|
||||
if ($application?->additional_servers?->count() > 0) {
|
||||
$servers = $servers->merge($application->additional_servers);
|
||||
|
|
@ -26,6 +27,7 @@ public function handle(Application $application, bool $previewDeployments = fals
|
|||
}
|
||||
|
||||
if ($server->isSwarm()) {
|
||||
$containerPresent = false;
|
||||
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
|
||||
|
||||
continue;
|
||||
|
|
@ -39,13 +41,15 @@ public function handle(Application $application, bool $previewDeployments = fals
|
|||
$timeout = $application->settings->stopGracePeriodSeconds();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
dockerStopCommand($timeout, $containerName, $server),
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
$commands = [dockerStopCommand($timeout, $containerName, $server)];
|
||||
if ($removeContainers) {
|
||||
$commands[] = "docker rm -f $containerName";
|
||||
}
|
||||
|
||||
instant_remote_process(command: $commands, server: $server, throwError: false);
|
||||
}
|
||||
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
if ($removeContainers && $application->build_pack === 'dockercompose') {
|
||||
$application->deleteConnectedNetworks();
|
||||
}
|
||||
|
||||
|
|
@ -57,16 +61,22 @@ public function handle(Application $application, bool $previewDeployments = fals
|
|||
}
|
||||
}
|
||||
|
||||
$status = ['status' => 'exited'];
|
||||
$status = [
|
||||
'status' => 'exited',
|
||||
'container_present' => $containerPresent,
|
||||
];
|
||||
if ($resetRestartCount) {
|
||||
$status = array_merge($status, [
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
'restart_limit_reached' => false,
|
||||
]);
|
||||
}
|
||||
$application->update($status);
|
||||
|
||||
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
app/Actions/Application/StopApplicationPreview.php
Normal file
34
app/Actions/Application/StopApplicationPreview.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Application;
|
||||
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopApplicationPreview
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(ApplicationPreview $preview, bool $resetRestartCount = true, bool $removeContainer = true): void
|
||||
{
|
||||
$application = $preview->application;
|
||||
$server = $application->destination->server;
|
||||
$containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id);
|
||||
|
||||
foreach ($containers->pluck('Names') as $containerName) {
|
||||
$commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)];
|
||||
if ($removeContainer) {
|
||||
$commands[] = "docker rm -f $containerName";
|
||||
}
|
||||
instant_remote_process($commands, $server, false);
|
||||
}
|
||||
|
||||
$preview->update(['status' => 'exited']);
|
||||
if ($resetRestartCount) {
|
||||
$preview->resetRestartLimit();
|
||||
}
|
||||
|
||||
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,11 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
|
|||
if (! $server->isFunctional()) {
|
||||
return 'Server is not functional';
|
||||
}
|
||||
$database->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
switch ($database->getMorphClass()) {
|
||||
case StandalonePostgresql::class:
|
||||
$activity = StartPostgresql::run($database);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Models\BaseModel;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
|
|
@ -18,7 +19,7 @@ class StopDatabase
|
|||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true)
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true): string
|
||||
{
|
||||
try {
|
||||
$server = $database->destination->server;
|
||||
|
|
@ -26,15 +27,17 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
|
|||
return 'Server is not functional';
|
||||
}
|
||||
|
||||
$this->stopContainer($database, $database->uuid, 30);
|
||||
$this->stopContainer($database, $database->uuid, 30, $removeContainer);
|
||||
|
||||
// Reset restart tracking when database is manually stopped
|
||||
$database->update([
|
||||
'status' => 'exited',
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
$database->update(['status' => 'exited']);
|
||||
if ($resetRestartCount) {
|
||||
$database->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
|
|
@ -53,12 +56,13 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
|
|||
|
||||
}
|
||||
|
||||
private function stopContainer($database, string $containerName, int $timeout = 30): void
|
||||
private function stopContainer(BaseModel $database, string $containerName, int $timeout = 30, bool $removeContainer = true): void
|
||||
{
|
||||
$server = $database->destination->server;
|
||||
instant_remote_process(command: [
|
||||
dockerStopCommand($timeout, $containerName, $server),
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
$commands = [dockerStopCommand($timeout, $containerName, $server)];
|
||||
if ($removeContainer) {
|
||||
$commands[] = "docker rm -f $containerName";
|
||||
}
|
||||
instant_remote_process(command: $commands, server: $server, throwError: false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,19 @@
|
|||
namespace App\Actions\Docker;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Application\StopApplicationPreview;
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Actions\Shared\ComplexStatusCheck;
|
||||
use App\Events\ServiceChecked;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Services\RestartCountTracker;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
|
@ -37,8 +41,12 @@ class GetContainersStatus
|
|||
|
||||
protected ?Collection $applicationContainerRestartCounts;
|
||||
|
||||
protected ?Collection $previewContainerRestartCounts;
|
||||
|
||||
protected ?Collection $serviceContainerStatuses;
|
||||
|
||||
protected ?Collection $serviceContainerRestartCounts;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
|
|
@ -117,6 +125,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
$containerStatus = "$containerStatus:$healthSuffix";
|
||||
}
|
||||
$labels = Arr::undot(format_docker_labels_to_json($labels));
|
||||
if (filter_var(data_get($labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) {
|
||||
continue;
|
||||
}
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
if ($applicationId) {
|
||||
$pullRequestId = data_get($labels, 'coolify.pullRequestId');
|
||||
|
|
@ -133,6 +144,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
} else {
|
||||
$preview->update(['last_online_at' => now()]);
|
||||
}
|
||||
$key = $applicationId.':'.$pullRequestId;
|
||||
$this->previewContainerRestartCounts ??= collect();
|
||||
$this->previewContainerRestartCounts->push([
|
||||
'key' => $key,
|
||||
'count' => (int) data_get($container, 'RestartCount', 0),
|
||||
]);
|
||||
} else {
|
||||
// Notify user that this container should not be there.
|
||||
}
|
||||
|
|
@ -140,6 +157,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if ($application) {
|
||||
$foundApplications[] = $application->id;
|
||||
if ($application->container_present !== true) {
|
||||
$application->update(['container_present' => true]);
|
||||
}
|
||||
// Store container status for aggregation
|
||||
if (! isset($this->applicationContainerStatuses)) {
|
||||
$this->applicationContainerStatuses = collect();
|
||||
|
|
@ -220,23 +240,22 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
|
||||
// Track restart count for databases (single-container)
|
||||
$restartCount = data_get($container, 'RestartCount', 0);
|
||||
$previousRestartCount = $database->restart_count ?? 0;
|
||||
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$updateData = ['status' => $containerStatus];
|
||||
} else {
|
||||
$updateData = ['last_online_at' => now()];
|
||||
}
|
||||
|
||||
// Update restart tracking if restart count increased
|
||||
if ($restartCount > $previousRestartCount) {
|
||||
$updateData['restart_count'] = $restartCount;
|
||||
$updateData['last_restart_at'] = now();
|
||||
$updateData['last_restart_type'] = 'crash';
|
||||
}
|
||||
|
||||
$database->update($updateData);
|
||||
|
||||
if ($restartCount > ($database->restart_count ?? 0)) {
|
||||
$database->update([
|
||||
'restart_count' => (int) $restartCount,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'crash',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($isPublic) {
|
||||
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
|
||||
if ($this->server->isSwarm()) {
|
||||
|
|
@ -292,6 +311,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
$containerName = data_get($labels, 'com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
|
||||
$this->serviceContainerRestartCounts ??= collect();
|
||||
if (! $this->serviceContainerRestartCounts->has($key)) {
|
||||
$this->serviceContainerRestartCounts->put($key, collect());
|
||||
}
|
||||
$this->serviceContainerRestartCounts->get($key)->put($containerName, (int) data_get($container, 'RestartCount', 0));
|
||||
}
|
||||
|
||||
// Mark service as found
|
||||
|
|
@ -335,46 +359,37 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
continue;
|
||||
}
|
||||
|
||||
$name = data_get($exitedService, 'name');
|
||||
$fqdn = data_get($exitedService, 'fqdn');
|
||||
if ($name) {
|
||||
if ($fqdn) {
|
||||
$containerName = "$name, available at $fqdn";
|
||||
} else {
|
||||
$containerName = $name;
|
||||
}
|
||||
} else {
|
||||
if ($fqdn) {
|
||||
$containerName = $fqdn;
|
||||
} else {
|
||||
$containerName = null;
|
||||
}
|
||||
if ($exitedService instanceof ServiceDatabase) {
|
||||
$exitedService->update(['status' => 'exited']);
|
||||
} elseif (! $exitedService->stoppedAfterRestartLimit()) {
|
||||
$exitedService->update([
|
||||
'status' => 'exited',
|
||||
'restart_count' => 0,
|
||||
'restart_limit_reached' => false,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
}
|
||||
$projectUuid = data_get($service, 'environment.project.uuid');
|
||||
$serviceUuid = data_get($service, 'uuid');
|
||||
$environmentName = data_get($service, 'environment.name');
|
||||
|
||||
if ($projectUuid && $serviceUuid && $environmentName) {
|
||||
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/service/'.$serviceUuid;
|
||||
} else {
|
||||
$url = null;
|
||||
}
|
||||
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
|
||||
$exitedService->update(['status' => 'exited']);
|
||||
}
|
||||
|
||||
$notRunningApplications = $this->applications->pluck('id')->diff($foundApplications);
|
||||
foreach ($notRunningApplications as $applicationId) {
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
if (str($application->status)->startsWith('exited')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only protection: If no containers at all, Docker query might have failed
|
||||
if ($this->containers->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str($application->status)->startsWith('exited')) {
|
||||
$application->update([
|
||||
'container_present' => false,
|
||||
'restart_limit_reached' => false,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// If container was recently restarting (crash loop), keep it as degraded for a grace period
|
||||
// This prevents false "exited" status during the brief moment between container removal and recreation
|
||||
$recentlyRestarted = $application->restart_count > 0 &&
|
||||
|
|
@ -388,9 +403,11 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
// Reset restart count when application exits completely
|
||||
$application->update([
|
||||
'status' => 'exited',
|
||||
'container_present' => false,
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
'restart_limit_reached' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -433,23 +450,10 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
StopDatabaseProxy::run($database);
|
||||
}
|
||||
|
||||
$name = data_get($database, 'name');
|
||||
$fqdn = data_get($database, 'fqdn');
|
||||
|
||||
$containerName = $name;
|
||||
|
||||
$projectUuid = data_get($database, 'environment.project.uuid');
|
||||
$environmentName = data_get($database, 'environment.name');
|
||||
$databaseUuid = data_get($database, 'uuid');
|
||||
|
||||
if ($projectUuid && $databaseUuid && $environmentName) {
|
||||
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/database/'.$databaseUuid;
|
||||
} else {
|
||||
$url = null;
|
||||
}
|
||||
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
|
||||
}
|
||||
|
||||
$this->trackPreviewRestartCounts($previews);
|
||||
|
||||
// Aggregate multi-container application statuses
|
||||
if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
|
||||
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
||||
|
|
@ -470,21 +474,21 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
|
||||
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
|
||||
$previousRestartCount = $application->restart_count ?? 0;
|
||||
$restartState = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: $previousRestartCount,
|
||||
observedRestartCount: $maxRestartCount,
|
||||
maxRestartCount: $application->max_restart_count ?? 0,
|
||||
);
|
||||
|
||||
if ($maxRestartCount > $previousRestartCount) {
|
||||
// Restart count increased - this is a crash restart
|
||||
if ($restartState['restart_count_changed']) {
|
||||
$hasCrashRestarts = $restartState['restart_count'] > 0;
|
||||
$application->update([
|
||||
'restart_count' => $maxRestartCount,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'crash',
|
||||
'restart_count' => $restartState['restart_count'],
|
||||
'last_restart_at' => $hasCrashRestarts ? now() : null,
|
||||
'last_restart_type' => $hasCrashRestarts ? 'crash' : null,
|
||||
]);
|
||||
|
||||
// Check if restart limit has been reached
|
||||
$maxAllowedRestarts = $application->max_restart_count ?? 0;
|
||||
if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
|
||||
$restartLimitReached = true;
|
||||
}
|
||||
}
|
||||
$restartLimitReached = $restartState['restart_limit_reached'];
|
||||
|
||||
// Aggregate status after tracking restart counts
|
||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
|
||||
|
|
@ -499,9 +503,22 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
});
|
||||
|
||||
if ($restartLimitReached) {
|
||||
$application->refresh();
|
||||
StopApplication::dispatch($application, false, true, false);
|
||||
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
|
||||
$restartLimitClaimed = Application::query()
|
||||
->whereKey($application->getKey())
|
||||
->where('restart_limit_reached', false)
|
||||
->update(['restart_limit_reached' => true]) === 1;
|
||||
|
||||
if ($restartLimitClaimed) {
|
||||
$application->refresh();
|
||||
StopApplication::dispatch(
|
||||
application: $application,
|
||||
previewDeployments: false,
|
||||
dockerCleanup: false,
|
||||
resetRestartCount: false,
|
||||
removeContainers: false,
|
||||
);
|
||||
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -562,6 +579,16 @@ private function aggregateServiceContainerStatuses($services)
|
|||
continue;
|
||||
}
|
||||
|
||||
$restartCount = isset($this->serviceContainerRestartCounts)
|
||||
? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0)
|
||||
: 0;
|
||||
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
|
||||
StopServiceApplication::dispatch($subResource, false, false);
|
||||
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse docker compose from service to check for excluded containers
|
||||
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
|
@ -602,4 +629,24 @@ private function aggregateServiceContainerStatuses($services)
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function trackPreviewRestartCounts(Collection $previews): void
|
||||
{
|
||||
if (! isset($this->previewContainerRestartCounts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->previewContainerRestartCounts
|
||||
->groupBy('key')
|
||||
->each(function (Collection $counts, string $key) use ($previews): void {
|
||||
[$applicationId, $pullRequestId] = explode(':', $key);
|
||||
$preview = $previews->first(fn (ApplicationPreview $preview): bool => (string) $preview->application_id === $applicationId
|
||||
&& (string) $preview->pull_request_id === $pullRequestId
|
||||
);
|
||||
if ($preview?->trackRestartCount((int) $counts->max('count'))) {
|
||||
StopApplicationPreview::dispatch($preview, false, false);
|
||||
$preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
|
|||
}
|
||||
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
|
||||
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
|
||||
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
|
||||
instant_remote_process([
|
||||
'docker rm -f coolify-sentinel || true',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
|
|||
}
|
||||
$service->saveComposeConfigs();
|
||||
$service->isConfigurationChanged(save: true);
|
||||
$service->applications()->get()->each->resetRestartLimit();
|
||||
$workdir = $service->workdir();
|
||||
// $commands[] = "cd {$workdir}";
|
||||
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
|
||||
|
|
|
|||
|
|
@ -49,8 +49,13 @@ public function handle(Service $service, bool $deleteConnectedNetworks = false,
|
|||
$this->stopContainersInParallel($containersToStop, $server);
|
||||
}
|
||||
|
||||
$applications->each->update(['status' => 'exited']);
|
||||
$dbs->each->update(['status' => 'exited']);
|
||||
$applications->each(function ($application): void {
|
||||
$application->update(['status' => 'exited']);
|
||||
$application->resetRestartLimit();
|
||||
});
|
||||
$dbs->each(function ($database): void {
|
||||
$database->update(['status' => 'exited']);
|
||||
});
|
||||
|
||||
if ($deleteConnectedNetworks) {
|
||||
$service->deleteConnectedNetworks();
|
||||
|
|
|
|||
|
|
@ -13,17 +13,23 @@ class StopServiceApplication
|
|||
|
||||
public string $jobQueue = 'high';
|
||||
|
||||
public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void
|
||||
public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $resetRestartCount = true, bool $removeContainer = false): void
|
||||
{
|
||||
$service = $serviceApplication->service;
|
||||
$server = $service->destination->server;
|
||||
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
|
||||
|
||||
instant_remote_process([
|
||||
"docker stop {$containerName}",
|
||||
], $server);
|
||||
if ($removeContainer) {
|
||||
$commands = ["docker rm -f {$containerName}"];
|
||||
} else {
|
||||
$commands = ["docker stop {$containerName}"];
|
||||
}
|
||||
instant_remote_process($commands, $server, throwError: ! $removeContainer);
|
||||
|
||||
$serviceApplication->update(['status' => 'exited']);
|
||||
if ($resetRestartCount && $serviceApplication instanceof ServiceApplication) {
|
||||
$serviceApplication->resetRestartLimit();
|
||||
}
|
||||
ServiceStatusChanged::dispatch($service->environment->project->team->id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ public function execute(ServiceApplication $serviceApplication, Request $request
|
|||
}
|
||||
}
|
||||
|
||||
$serviceApplication->fqdn = $parsed['normalized'];
|
||||
$serviceApplication->setEditableUrls($parsed['normalized']);
|
||||
}
|
||||
|
||||
if (array_key_exists('noindex_domains', $payload)) {
|
||||
|
|
|
|||
|
|
@ -47,9 +47,10 @@ protected function schedule(Schedule $schedule): void
|
|||
->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
|
||||
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
|
||||
$this->scheduleInstance->command('cleanup:stucked-resources')
|
||||
->daily()
|
||||
->dailyAt('03:17')
|
||||
->onOneServer()
|
||||
->withoutOverlapping(60);
|
||||
->withoutOverlapping(60)
|
||||
->runInBackground();
|
||||
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
|
||||
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Services\DockerImageParser;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -2487,6 +2488,256 @@ public function logs_by_uuid(Request $request)
|
|||
]);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Preview Domains',
|
||||
description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.',
|
||||
path: '/applications/{uuid}/previews/{pull_request_id}',
|
||||
operationId: 'update-preview-domains-by-pull-request-id',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'pull_request_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'domains', type: 'string', nullable: true, example: 'https://pr.example.com:3000'),
|
||||
new OA\Property(
|
||||
property: 'docker_compose_domains',
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: new OA\Items(properties: [
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'domain', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'redirect', type: 'string', nullable: true, enum: ['www', 'non-www', 'both']),
|
||||
], type: 'object'),
|
||||
),
|
||||
new OA\Property(property: 'force_domain_override', type: 'boolean', default: false),
|
||||
],
|
||||
)),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Preview domains updated.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, ref: '#/components/responses/403'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 409, description: 'Domain conflict.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update_preview_by_pull_request_id(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$pullRequestIdRaw = $request->route('pull_request_id');
|
||||
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
|
||||
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
|
||||
}
|
||||
|
||||
$preview = ApplicationPreview::where('application_id', $application->id)
|
||||
->where('pull_request_id', (int) $pullRequestIdRaw)
|
||||
->first();
|
||||
if (! $preview) {
|
||||
return response()->json(['message' => 'Preview not found.'], 404);
|
||||
}
|
||||
|
||||
$isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value;
|
||||
$validationRules = ['force_domain_override' => 'boolean'];
|
||||
if ($isCompose) {
|
||||
$validationRules = array_merge($validationRules, [
|
||||
'domains' => 'missing',
|
||||
'docker_compose_domains' => 'present|array',
|
||||
'docker_compose_domains.*' => 'array:name,domain,redirect',
|
||||
'docker_compose_domains.*.name' => 'required|string|distinct',
|
||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||
]);
|
||||
} else {
|
||||
$validationRules['domains'] = ['present', ...ValidationPatterns::applicationDomainRules()];
|
||||
$validationRules['docker_compose_domains'] = 'missing';
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), $validationRules);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$dockerComposeDomains = null;
|
||||
$dockerComposeDomainsResponse = null;
|
||||
if ($isCompose) {
|
||||
try {
|
||||
$compose = Yaml::parse($application->docker_compose_raw ?? '');
|
||||
} catch (\Throwable) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['docker_compose_domains' => 'The Docker Compose configuration could not be parsed.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$services = data_get($compose, 'services');
|
||||
if (! is_array($services) || $services === []) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['docker_compose_domains' => 'The Docker Compose configuration must define at least one service.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$composeServices = collect($services)
|
||||
->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image')))
|
||||
->keys()
|
||||
->map(fn (mixed $name): string => (string) $name)
|
||||
->values();
|
||||
$requestedServices = collect($request->input('docker_compose_domains'))->pluck('name');
|
||||
if ($requestedServices->diff($composeServices)->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['docker_compose_domains' => 'One or more Docker Compose services are invalid.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$existingComposeDomains = json_decode($preview->docker_compose_domains ?? '[]', true) ?: [];
|
||||
$dockerComposeDomains = $composeServices
|
||||
->mapWithKeys(function (string $service) use ($existingComposeDomains): array {
|
||||
$entry = ['domain' => ''];
|
||||
$redirect = $existingComposeDomains[$service]['redirect'] ?? null;
|
||||
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
|
||||
$entry['redirect'] = $redirect;
|
||||
}
|
||||
|
||||
return [$service => $entry];
|
||||
})
|
||||
->all();
|
||||
foreach ($request->input('docker_compose_domains') as $item) {
|
||||
$entry = ['domain' => ValidationPatterns::normalizeApplicationDomains(data_get($item, 'domain')) ?? ''];
|
||||
$redirect = array_key_exists('redirect', $item)
|
||||
? data_get($item, 'redirect')
|
||||
: ($existingComposeDomains[data_get($item, 'name')]['redirect'] ?? null);
|
||||
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
|
||||
$entry['redirect'] = $redirect;
|
||||
}
|
||||
$dockerComposeDomains[data_get($item, 'name')] = $entry;
|
||||
}
|
||||
$domains = collect($dockerComposeDomains)
|
||||
->pluck('domain')
|
||||
->filter()
|
||||
->implode(',') ?: null;
|
||||
} else {
|
||||
$domains = ValidationPatterns::normalizeApplicationDomains($request->input('domains'));
|
||||
}
|
||||
|
||||
$submittedUrls = collect(ValidationPatterns::applicationDomainList($domains))
|
||||
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain));
|
||||
if ($submittedUrls->duplicates()->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
$isCompose ? 'docker_compose_domains' : 'domains' => 'The same domain cannot be configured more than once.',
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$normalized = DomainPortOverrides::normalize($domains, null);
|
||||
$portlessDomains = $normalized['fqdn'];
|
||||
if ($isCompose) {
|
||||
foreach ($dockerComposeDomains as $service => $entry) {
|
||||
$dockerComposeDomains[$service]['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain']))
|
||||
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
|
||||
->implode(',');
|
||||
}
|
||||
$dockerComposeDomainsResponse = collect($dockerComposeDomains)
|
||||
->map(fn (array $entry, string $name): array => ['name' => $name, ...$entry])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
$urls = collect(ValidationPatterns::applicationDomainList($portlessDomains));
|
||||
$conflicts = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId);
|
||||
if (isset($conflicts['error'])) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [$isCompose ? 'docker_compose_domains' : 'domains' => $conflicts['error']],
|
||||
], 422);
|
||||
}
|
||||
if ($conflicts['hasConflicts'] && ! $request->boolean('force_domain_override')) {
|
||||
return response()->json([
|
||||
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
|
||||
'conflicts' => $conflicts['conflicts'],
|
||||
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$hostCandidates = $urls
|
||||
->map(fn (string $url): string => (string) parse_url($url, PHP_URL_HOST))
|
||||
->filter();
|
||||
$conflictingPreview = null;
|
||||
if ($hostCandidates->isNotEmpty()) {
|
||||
$conflictingPreview = ApplicationPreview::query()
|
||||
->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId)
|
||||
->withoutGlobalScope('withRelations')
|
||||
->reorder()
|
||||
->select('applications.id'))
|
||||
->whereKeyNot($preview->id)
|
||||
->whereNotNull('fqdn')
|
||||
->where(function ($query) use ($hostCandidates): void {
|
||||
foreach ($hostCandidates as $host) {
|
||||
$query->orWhere('fqdn', 'like', '%'.$host.'%');
|
||||
}
|
||||
})
|
||||
->get(['uuid', 'pull_request_id', 'fqdn'])
|
||||
->first(fn (ApplicationPreview $otherPreview): bool => collect(ValidationPatterns::applicationDomainList($otherPreview->fqdn))
|
||||
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
|
||||
->intersect($urls)
|
||||
->isNotEmpty());
|
||||
}
|
||||
|
||||
if ($conflictingPreview && ! $request->boolean('force_domain_override')) {
|
||||
return response()->json([
|
||||
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
|
||||
'conflicts' => [[
|
||||
'domain' => collect(ValidationPatterns::applicationDomainList($conflictingPreview->fqdn))
|
||||
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
|
||||
->intersect($urls)
|
||||
->first(),
|
||||
'resource_name' => 'Preview deployment #'.$conflictingPreview->pull_request_id,
|
||||
'resource_uuid' => $conflictingPreview->uuid,
|
||||
'resource_type' => 'application',
|
||||
'message' => 'Domain is already in use by another preview deployment.',
|
||||
]],
|
||||
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$preview->domain_port_overrides = $normalized['overrides'];
|
||||
$preview->fqdn = $portlessDomains;
|
||||
if ($isCompose) {
|
||||
$preview->docker_compose_domains = json_encode($dockerComposeDomains);
|
||||
}
|
||||
$preview->save();
|
||||
|
||||
auditLog('api.application.preview_updated', [
|
||||
'team_id' => $teamId,
|
||||
'application_uuid' => $application->uuid,
|
||||
'pull_request_id' => $preview->pull_request_id,
|
||||
'changed_fields' => [$isCompose ? 'docker_compose_domains' : 'domains'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $preview->uuid,
|
||||
'pull_request_id' => $preview->pull_request_id,
|
||||
'domains' => $preview->fqdn,
|
||||
'docker_compose_domains' => $dockerComposeDomainsResponse,
|
||||
'domain_port_overrides' => $preview->domain_port_overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete',
|
||||
description: 'Delete application by UUID.',
|
||||
|
|
@ -2818,6 +3069,7 @@ public function update_by_uuid(Request $request)
|
|||
'http_basic_auth_username' => 'string',
|
||||
'http_basic_auth_password' => 'string',
|
||||
'include_source_commit_in_build' => 'boolean',
|
||||
'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/',
|
||||
];
|
||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||
$validationMessages = [
|
||||
|
|
@ -2826,10 +3078,10 @@ public function update_by_uuid(Request $request)
|
|||
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||
|
||||
// Validate ports_exposes
|
||||
if ($request->has('ports_exposes')) {
|
||||
if ($request->filled('ports_exposes')) {
|
||||
$ports = explode(',', $request->ports_exposes);
|
||||
foreach ($ports as $port) {
|
||||
if (! is_numeric($port)) {
|
||||
if (! is_numeric($port) || (int) $port < 1 || (int) $port > 65535) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
|
|
@ -5152,7 +5404,7 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
|
|||
$this->authorize('delete', $application);
|
||||
|
||||
$pullRequestIdRaw = $request->route('pull_request_id');
|
||||
if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
|
||||
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
|
||||
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
|
||||
}
|
||||
$pullRequestId = (int) $pullRequestIdRaw;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class NotificationsController extends Controller
|
||||
|
|
@ -45,6 +46,7 @@ private function channelConfig(string $channel): array
|
|||
'deployment_success_email_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_email_notifications' => 'sometimes|boolean',
|
||||
'status_change_email_notifications' => 'sometimes|boolean',
|
||||
'restart_limit_reached_email_notifications' => 'sometimes|boolean',
|
||||
'backup_success_email_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_email_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_email_notifications' => 'sometimes|boolean',
|
||||
|
|
@ -66,6 +68,7 @@ private function channelConfig(string $channel): array
|
|||
'deployment_success_discord_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_discord_notifications' => 'sometimes|boolean',
|
||||
'status_change_discord_notifications' => 'sometimes|boolean',
|
||||
'restart_limit_reached_discord_notifications' => 'sometimes|boolean',
|
||||
'backup_success_discord_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_discord_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_discord_notifications' => 'sometimes|boolean',
|
||||
|
|
@ -88,6 +91,7 @@ private function channelConfig(string $channel): array
|
|||
'deployment_success_slack_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_slack_notifications' => 'sometimes|boolean',
|
||||
'status_change_slack_notifications' => 'sometimes|boolean',
|
||||
'restart_limit_reached_slack_notifications' => 'sometimes|boolean',
|
||||
'backup_success_slack_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_slack_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_slack_notifications' => 'sometimes|boolean',
|
||||
|
|
@ -110,6 +114,7 @@ private function channelConfig(string $channel): array
|
|||
'deployment_success_telegram_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_telegram_notifications' => 'sometimes|boolean',
|
||||
'status_change_telegram_notifications' => 'sometimes|boolean',
|
||||
'restart_limit_reached_telegram_notifications' => 'sometimes|boolean',
|
||||
'backup_success_telegram_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_telegram_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_telegram_notifications' => 'sometimes|boolean',
|
||||
|
|
@ -124,6 +129,7 @@ private function channelConfig(string $channel): array
|
|||
'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_restart_limit_reached_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_backup_success_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_backup_failure_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
'telegram_notifications_scheduled_task_success_thread_id' => 'sometimes|nullable|string|max:255',
|
||||
|
|
@ -146,6 +152,7 @@ private function channelConfig(string $channel): array
|
|||
'deployment_success_pushover_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_pushover_notifications' => 'sometimes|boolean',
|
||||
'status_change_pushover_notifications' => 'sometimes|boolean',
|
||||
'restart_limit_reached_pushover_notifications' => 'sometimes|boolean',
|
||||
'backup_success_pushover_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_pushover_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_pushover_notifications' => 'sometimes|boolean',
|
||||
|
|
@ -167,6 +174,7 @@ private function channelConfig(string $channel): array
|
|||
'deployment_success_webhook_notifications' => 'sometimes|boolean',
|
||||
'deployment_failure_webhook_notifications' => 'sometimes|boolean',
|
||||
'status_change_webhook_notifications' => 'sometimes|boolean',
|
||||
'restart_limit_reached_webhook_notifications' => 'sometimes|boolean',
|
||||
'backup_success_webhook_notifications' => 'sometimes|boolean',
|
||||
'backup_failure_webhook_notifications' => 'sometimes|boolean',
|
||||
'scheduled_task_success_webhook_notifications' => 'sometimes|boolean',
|
||||
|
|
@ -249,7 +257,7 @@ private function updateChannel(Request $request, string $channel): JsonResponse
|
|||
$body = $request->json()->all();
|
||||
$config = $this->channelConfig($channel);
|
||||
|
||||
$validator = customApiValidator($body, $config['rules']);
|
||||
$validator = Validator::make($body, $config['rules']);
|
||||
|
||||
$extraFields = array_diff(array_keys($body), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
|
|||
/**
|
||||
* Build a stable hash of container state.
|
||||
*
|
||||
* Covers [name, state] only — metrics, filesystem_usage_root, and
|
||||
* Covers [name, state, restart_count] only — metrics, filesystem_usage_root, and
|
||||
* health_status are excluded on purpose. Disk % churns constantly, and
|
||||
* health checks can flap between starting/healthy/unhealthy while the
|
||||
* container lifecycle state remains unchanged. Both would otherwise defeat
|
||||
|
|
@ -153,6 +153,7 @@ private function containerStateHash(array $data): string
|
|||
->map(fn ($c) => [
|
||||
'name' => data_get($c, 'name'),
|
||||
'state' => data_get($c, 'state'),
|
||||
'restart_count' => data_get($c, 'restart_count'),
|
||||
])
|
||||
->sortBy('name')
|
||||
->values()
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env';
|
||||
|
||||
public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env';
|
||||
|
||||
public const BUILD_TIME_ENV_LAUNCHER_PATH = '/artifacts/run-with-build-time-env';
|
||||
|
||||
private const BUILD_SCRIPT_PATH = '/artifacts/build.sh';
|
||||
|
||||
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
|
||||
|
|
@ -201,6 +205,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
private bool $dockerSecretsSupported = false;
|
||||
|
||||
private bool $dockerSecretsAvailable = false;
|
||||
|
||||
private bool $useBuildtimeEnvironmentLauncher = false;
|
||||
|
||||
private bool $skip_build = false;
|
||||
|
||||
private Collection|string $build_secrets;
|
||||
|
|
@ -261,14 +269,7 @@ public function __construct(public int $application_deployment_queue_id)
|
|||
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
|
||||
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
|
||||
|
||||
$this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id);
|
||||
if ($this->application->settings->custom_internal_name && ! $this->application->settings->is_consistent_container_name_enabled) {
|
||||
if ($this->pull_request_id === 0) {
|
||||
$this->container_name = $this->application->settings->custom_internal_name;
|
||||
} else {
|
||||
$this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
|
||||
}
|
||||
}
|
||||
$this->container_name = $this->resolveContainerName();
|
||||
|
||||
$this->saved_outputs = collect();
|
||||
|
||||
|
|
@ -425,6 +426,11 @@ public function handle(): void
|
|||
|
||||
private function detectBuildKitCapabilities(): void
|
||||
{
|
||||
$this->dockerBuildkitSupported = false;
|
||||
$this->dockerBuildxAvailable = false;
|
||||
$this->dockerSecretsSupported = false;
|
||||
$this->dockerSecretsAvailable = false;
|
||||
|
||||
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
|
||||
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
|
||||
|
||||
|
|
@ -475,18 +481,19 @@ private function detectBuildKitCapabilities(): void
|
|||
}
|
||||
}
|
||||
|
||||
// If build secrets are enabled and BuildKit is available, verify --secret flag support
|
||||
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
|
||||
if ($this->dockerBuildkitSupported) {
|
||||
$secretsTest = instant_remote_process(
|
||||
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
|
||||
$serverToCheck
|
||||
);
|
||||
|
||||
if (trim($secretsTest) === 'supported') {
|
||||
$this->dockerSecretsSupported = true;
|
||||
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
|
||||
} else {
|
||||
$this->dockerSecretsSupported = false;
|
||||
$this->dockerSecretsAvailable = true;
|
||||
if ($this->application->settings->use_build_secrets) {
|
||||
$this->dockerSecretsSupported = true;
|
||||
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
|
||||
}
|
||||
} elseif ($this->application->settings->use_build_secrets) {
|
||||
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
|
||||
}
|
||||
}
|
||||
|
|
@ -494,6 +501,7 @@ private function detectBuildKitCapabilities(): void
|
|||
$this->dockerBuildkitSupported = false;
|
||||
$this->dockerBuildxAvailable = false;
|
||||
$this->dockerSecretsSupported = false;
|
||||
$this->dockerSecretsAvailable = false;
|
||||
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1638,11 +1646,14 @@ private function generate_buildtime_environment_variables()
|
|||
}
|
||||
|
||||
foreach ($planVariables as $key => $value) {
|
||||
$key = (string) $key;
|
||||
|
||||
// Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority
|
||||
if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->validatedBuildtimeEnvironmentVariableKey($key, 'the Nixpacks plan');
|
||||
$escapedValue = escapeBashEnvValue($value);
|
||||
$envs_dict[$key] = $escapedValue;
|
||||
|
||||
|
|
@ -1830,6 +1841,7 @@ private function generate_buildtime_environment_variables()
|
|||
// Convert dictionary back to collection in KEY=VALUE format
|
||||
$envs = collect([]);
|
||||
foreach ($envs_dict as $key => $value) {
|
||||
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build-time environment');
|
||||
$envs->push($key.'='.$value);
|
||||
}
|
||||
|
||||
|
|
@ -1843,44 +1855,130 @@ private function generate_buildtime_environment_variables()
|
|||
return $envs;
|
||||
}
|
||||
|
||||
private function save_buildtime_environment_variables()
|
||||
private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string
|
||||
{
|
||||
// Generate build-time environment variables locally
|
||||
$environment_variables = $this->generate_buildtime_environment_variables();
|
||||
|
||||
// Save .env file for build phase in /artifacts to prevent it from being copied into Docker images
|
||||
if ($environment_variables->isNotEmpty()) {
|
||||
$envs_base64 = base64_encode($environment_variables->implode("\n"));
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
|
||||
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
|
||||
]
|
||||
);
|
||||
|
||||
if (isDev()) {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH),
|
||||
'hidden' => true,
|
||||
]
|
||||
);
|
||||
try {
|
||||
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
|
||||
throw new \InvalidArgumentException('Invalid build-time environment variable key.');
|
||||
}
|
||||
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
|
||||
// For build packs that source the build-time .env file, create an empty file even if there are no build-time variables
|
||||
// This ensures the file exists when referenced in build commands
|
||||
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
|
||||
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH),
|
||||
]
|
||||
return $key;
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin);
|
||||
|
||||
throw new DeploymentException(
|
||||
"Invalid environment variable name from {$origin}: ".ValidationPatterns::displayShellEnvironmentVariableKey($key).'. Names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.',
|
||||
previous: $exception,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void
|
||||
{
|
||||
$displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key);
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr');
|
||||
$this->application_deployment_queue->addLogEntry("⚠️ Invalid environment variable name from {$origin}: {$displayKey}", 'stderr');
|
||||
$this->application_deployment_queue->addLogEntry('Build-time variable names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', 'stderr');
|
||||
$this->application_deployment_queue->addLogEntry('💡 How to fix:', type: 'info');
|
||||
|
||||
if ($origin === 'the Nixpacks plan') {
|
||||
$this->application_deployment_queue->addLogEntry(' 1. Open nixpacks.toml and check the [variables] section. Quoted keys can contain characters that are not valid environment variable names.', type: 'info');
|
||||
$this->application_deployment_queue->addLogEntry(' 2. Rename the key to a plain name like MY_VARIABLE (no spaces, shell syntax, or command substitutions).', type: 'info');
|
||||
$this->logSuggestedShellEnvironmentVariableKey($key);
|
||||
$this->application_deployment_queue->addLogEntry(' 3. Commit, push, and redeploy.', type: 'info');
|
||||
$this->application_deployment_queue->addLogEntry('Docs: https://nixpacks.com/docs/configuration/file', type: 'info');
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry(' Rename the environment variable to use only letters, numbers, and underscores, then redeploy.', type: 'info');
|
||||
$this->logSuggestedShellEnvironmentVariableKey($key);
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr');
|
||||
}
|
||||
|
||||
private function logSuggestedShellEnvironmentVariableKey(string $key): void
|
||||
{
|
||||
$suggestedKey = str_replace('.', '_', $key);
|
||||
if ($suggestedKey === $key || preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $suggestedKey) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$displaySuggestedKey = ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey);
|
||||
|
||||
$this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info');
|
||||
}
|
||||
|
||||
private function save_buildtime_environment_variables()
|
||||
{
|
||||
$environment_variables = $this->generate_buildtime_environment_variables();
|
||||
[$shell_environment_variables, $dotted_environment_variables] = $environment_variables->partition(function (string $environmentVariable): bool {
|
||||
[$key] = explode('=', $environmentVariable, 2);
|
||||
|
||||
return preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $key) === 1;
|
||||
});
|
||||
|
||||
if ($dotted_environment_variables->isEmpty()) {
|
||||
$this->useBuildtimeEnvironmentLauncher = false;
|
||||
|
||||
if ($environment_variables->isNotEmpty()) {
|
||||
$envs_base64 = base64_encode($environment_variables->implode("\n"));
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
|
||||
]);
|
||||
|
||||
if (isDev()) {
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH),
|
||||
'hidden' => true,
|
||||
]);
|
||||
}
|
||||
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
|
||||
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH),
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->useBuildtimeEnvironmentLauncher = true;
|
||||
|
||||
$launcher = [
|
||||
'#!/bin/bash',
|
||||
'set -a',
|
||||
'source '.self::BUILD_TIME_SHELL_ENV_PATH,
|
||||
'set +a',
|
||||
];
|
||||
|
||||
$launcher[] = 'exec env \\';
|
||||
foreach ($dotted_environment_variables as $environmentVariable) {
|
||||
$launcher[] = " {$environmentVariable} \\";
|
||||
}
|
||||
$launcher[] = ' "$@"';
|
||||
|
||||
$files = [
|
||||
self::BUILD_TIME_ENV_PATH => $environment_variables->implode("\n"),
|
||||
self::BUILD_TIME_SHELL_ENV_PATH => $shell_environment_variables->implode("\n"),
|
||||
self::BUILD_TIME_ENV_LAUNCHER_PATH => implode("\n", $launcher)."\n",
|
||||
];
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('Creating build-time environment files in /artifacts (outside Docker context).', hidden: true);
|
||||
|
||||
foreach ($files as $path => $contents) {
|
||||
$contents_base64 = base64_encode($contents);
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "echo '$contents_base64' | base64 -d | tee {$path} > /dev/null"),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, 'chmod 700 '.self::BUILD_TIME_ENV_LAUNCHER_PATH),
|
||||
]);
|
||||
}
|
||||
|
||||
private function elixir_finetunes()
|
||||
{
|
||||
if ($this->pull_request_id === 0) {
|
||||
|
|
@ -1986,6 +2084,19 @@ private function rolling_update()
|
|||
}
|
||||
}
|
||||
|
||||
private function resolveContainerName(): string
|
||||
{
|
||||
if (str($this->application->settings->custom_internal_name)->isEmpty()) {
|
||||
return generateApplicationContainerName($this->application, $this->pull_request_id);
|
||||
}
|
||||
|
||||
if ($this->pull_request_id === 0) {
|
||||
return $this->application->settings->custom_internal_name;
|
||||
}
|
||||
|
||||
return addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
|
||||
}
|
||||
|
||||
private function health_check()
|
||||
{
|
||||
try {
|
||||
|
|
@ -2251,12 +2362,8 @@ private function deploy_to_additional_destinations()
|
|||
destination: $destination,
|
||||
no_questions_asked: true,
|
||||
);
|
||||
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: ".route('project.application.deployment.show', [
|
||||
'project_uuid' => data_get($this->application, 'environment.project.uuid'),
|
||||
'application_uuid' => data_get($this->application, 'uuid'),
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
'environment_uuid' => data_get($this->application, 'environment.uuid'),
|
||||
]));
|
||||
$deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}";
|
||||
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2274,9 +2381,9 @@ private function set_coolify_variables()
|
|||
$fqdn = $this->preview->fqdn;
|
||||
}
|
||||
if (isset($fqdn)) {
|
||||
$url = Url::fromString($fqdn);
|
||||
$fqdn = $url->getHost();
|
||||
$url = $url->withHost($fqdn)->withPort(null)->__toString();
|
||||
$domains = str($fqdn)->explode(',')->map(fn (string $domain) => trim($domain))->filter();
|
||||
$url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(',');
|
||||
$fqdn = $domains->map(fn (string $domain) => Url::fromString($domain)->getHost())->implode(',');
|
||||
if ((int) $this->application->compose_parsing_version >= 3) {
|
||||
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
|
||||
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
|
||||
|
|
@ -3428,6 +3535,9 @@ private function generate_compose_file()
|
|||
$custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options);
|
||||
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
|
||||
$docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name];
|
||||
if ($this->container_name !== $this->application->uuid) {
|
||||
unset($docker_compose['services'][$this->container_name]);
|
||||
}
|
||||
if (count($custom_compose) > 0) {
|
||||
$ipv4 = data_get($custom_compose, 'ip.0');
|
||||
$ipv6 = data_get($custom_compose, 'ip6.0');
|
||||
|
|
@ -3644,7 +3754,13 @@ private function build_static_image()
|
|||
*/
|
||||
private function wrap_build_command_with_env_export(string $build_command): string
|
||||
{
|
||||
return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}";
|
||||
if (! $this->useBuildtimeEnvironmentLauncher) {
|
||||
return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}";
|
||||
}
|
||||
|
||||
$escapedBuildCommand = escapeBashEnvValue($build_command);
|
||||
|
||||
return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}";
|
||||
}
|
||||
|
||||
private function build_image()
|
||||
|
|
@ -4027,7 +4143,10 @@ private function stop_running_container(bool $force = false)
|
|||
$this->application_deployment_queue->addLogEntry('Removing old containers.');
|
||||
if ($this->newVersionIsHealthy || $force) {
|
||||
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->graceful_shutdown_container($this->container_name);
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
$this->containerNamesToRemove($containers)->each(function (string $containerName) {
|
||||
$this->graceful_shutdown_container($containerName);
|
||||
});
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
if ($this->pull_request_id === 0) {
|
||||
|
|
@ -4066,6 +4185,16 @@ private function stop_running_container(bool $force = false)
|
|||
}
|
||||
}
|
||||
|
||||
private function containerNamesToRemove(Collection $containers): Collection
|
||||
{
|
||||
return $containers
|
||||
->pluck('Names')
|
||||
->push($this->container_name)
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
|
||||
private function start_by_compose_file()
|
||||
{
|
||||
try {
|
||||
|
|
@ -4154,6 +4283,21 @@ private function generate_build_env_variables()
|
|||
$this->analyzeBuildTimeVariables($variables);
|
||||
}
|
||||
|
||||
$requiresDottedEnvironmentSecrets = $this->application->build_pack === 'nixpacks'
|
||||
&& $variables->keys()->contains(fn ($key): bool => str_contains((string) $key, '.'));
|
||||
|
||||
if ($requiresDottedEnvironmentSecrets) {
|
||||
if (! $this->dockerSecretsAvailable) {
|
||||
$dottedKeys = $variables->keys()
|
||||
->filter(fn ($key): bool => str_contains((string) $key, '.'))
|
||||
->implode(', ');
|
||||
|
||||
throw new DeploymentException("Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: {$dottedKeys}. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server.");
|
||||
}
|
||||
|
||||
$this->dockerSecretsSupported = true;
|
||||
}
|
||||
|
||||
if ($this->dockerSecretsSupported) {
|
||||
$this->generate_build_secrets($variables);
|
||||
$this->build_args = '';
|
||||
|
|
@ -4418,7 +4562,7 @@ private function add_build_env_variables_to_dockerfile()
|
|||
private function modify_dockerfile_for_secrets($dockerfile_path)
|
||||
{
|
||||
// Only process if build secrets are enabled and we have secrets to mount
|
||||
if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) {
|
||||
if (empty($this->build_secrets)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -4442,18 +4586,51 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
|
|||
$this->generate_env_variables();
|
||||
}
|
||||
|
||||
$variables = $this->env_args;
|
||||
$variables = $this->application->build_pack === 'nixpacks'
|
||||
? collect($this->nixpacks_plan_json->get('variables'))
|
||||
: $this->env_args;
|
||||
if ($variables->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dottedKeys = $variables->keys()
|
||||
->map(fn ($key): string => (string) $key)
|
||||
->filter(fn (string $key): bool => str_contains($key, '.'));
|
||||
|
||||
if ($dottedKeys->isNotEmpty()) {
|
||||
$originalDockerfile = $dockerfile;
|
||||
$dockerfile = $dockerfile->map(function (string $line) use ($dottedKeys): ?string {
|
||||
$trimmedLine = trim($line);
|
||||
|
||||
if (! str_starts_with($trimmedLine, 'ARG ') && ! str_starts_with($trimmedLine, 'ENV ')) {
|
||||
return $line;
|
||||
}
|
||||
|
||||
[$instruction, $arguments] = explode(' ', $trimmedLine, 2);
|
||||
$filteredArguments = collect(preg_split('/\s+/', $arguments))
|
||||
->reject(function (string $argument) use ($dottedKeys): bool {
|
||||
$key = str($argument)->before('=')->toString();
|
||||
|
||||
return $dottedKeys->contains($key);
|
||||
});
|
||||
|
||||
if ($filteredArguments->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $instruction.' '.$filteredArguments->implode(' ');
|
||||
})->filter()->values();
|
||||
|
||||
$modified = $dockerfile->all() !== $originalDockerfile->values()->all();
|
||||
}
|
||||
|
||||
// Generate mount strings for all secrets
|
||||
$mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' ');
|
||||
|
||||
// Add mount for the secrets hash to ensure cache invalidation
|
||||
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
|
||||
|
||||
$modified = false;
|
||||
$modified ??= false;
|
||||
$dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
|
||||
$trimmed = ltrim($line);
|
||||
|
||||
|
|
@ -4950,11 +5127,21 @@ private function handleSuccessfulDeployment(): void
|
|||
// Reset restart count after successful deployment
|
||||
// This is done here (not in Livewire) to avoid race conditions
|
||||
// with GetContainersStatus reading old container restart counts
|
||||
$this->application->update([
|
||||
$restartState = [
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
];
|
||||
|
||||
if ($this->pull_request_id === 0) {
|
||||
$restartState['restart_limit_reached'] = false;
|
||||
}
|
||||
|
||||
if ($this->pull_request_id === 0) {
|
||||
$this->application->update($restartState);
|
||||
} else {
|
||||
$this->preview?->resetRestartLimit();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->application->markDeploymentConfigurationApplied($this->application_deployment_queue);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -23,7 +24,7 @@ class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue
|
|||
public int $timeout = 30;
|
||||
|
||||
public function __construct(
|
||||
public Application|ServiceApplication $resource,
|
||||
public Application|ApplicationPreview|ServiceApplication $resource,
|
||||
public string $statusKey,
|
||||
public string $url,
|
||||
public ?Server $server,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Events\ProxyStatusChangedUI;
|
||||
use App\Models\Server;
|
||||
use App\Notifications\Server\TraefikVersionOutdated;
|
||||
|
|
@ -33,8 +35,13 @@ public function __construct(
|
|||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$this->server->refresh();
|
||||
$this->clearOutdatedInfo();
|
||||
|
||||
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect current version (makes SSH call)
|
||||
$currentVersion = getTraefikVersionFromDockerCompose($this->server);
|
||||
|
||||
|
|
@ -116,7 +123,10 @@ public function handle(): void
|
|||
|
||||
private function clearOutdatedInfo(): void
|
||||
{
|
||||
$this->server->update(['traefik_outdated_info' => null]);
|
||||
$this->server->update([
|
||||
'detected_traefik_version' => null,
|
||||
'traefik_outdated_info' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,6 +19,20 @@ class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
Server::query()
|
||||
->where(function ($query) {
|
||||
$query->whereNull('proxy')
|
||||
->orWhere('proxy->type', '!=', ProxyTypes::TRAEFIK->value);
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNotNull('detected_traefik_version')
|
||||
->orWhereNotNull('traefik_outdated_info');
|
||||
})
|
||||
->update([
|
||||
'detected_traefik_version' => null,
|
||||
'traefik_outdated_info' => null,
|
||||
]);
|
||||
|
||||
// Load versions from cached data
|
||||
$traefikVersions = get_traefik_versions();
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
|
|||
|
||||
public function __construct(public Server $server) {}
|
||||
|
||||
private static function helperContainersCommand(): string
|
||||
{
|
||||
return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\'';
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
|
|
@ -36,7 +41,7 @@ public function handle(): void
|
|||
'active_deployment_uuids' => $activeDeployments,
|
||||
]);
|
||||
|
||||
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false);
|
||||
$containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false);
|
||||
$helperContainers = collect(json_decode($containers));
|
||||
|
||||
if ($helperContainers->count() > 0) {
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ public function handle(): void
|
|||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
BackupCreated::dispatch($this->team->id);
|
||||
$this->backup_standalone_postgresql($database);
|
||||
} elseif (str($databaseType)->contains('mongo')) {
|
||||
if ($database === '*') {
|
||||
|
|
@ -343,6 +344,7 @@ public function handle(): void
|
|||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
BackupCreated::dispatch($this->team->id);
|
||||
$this->backup_standalone_mongodb($database);
|
||||
} elseif (str($databaseType)->contains('mysql')) {
|
||||
$this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp';
|
||||
|
|
@ -357,6 +359,7 @@ public function handle(): void
|
|||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
BackupCreated::dispatch($this->team->id);
|
||||
$this->backup_standalone_mysql($database);
|
||||
} elseif (str($databaseType)->contains('mariadb')) {
|
||||
$this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp';
|
||||
|
|
@ -371,6 +374,7 @@ public function handle(): void
|
|||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
BackupCreated::dispatch($this->team->id);
|
||||
$this->backup_standalone_mariadb($database);
|
||||
} elseif ($this->database instanceof StandaloneClickhouse) {
|
||||
$this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip";
|
||||
|
|
@ -382,6 +386,7 @@ public function handle(): void
|
|||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
BackupCreated::dispatch($this->team->id);
|
||||
$this->backup_standalone_clickhouse($database);
|
||||
} else {
|
||||
throw new \Exception('Unsupported database type');
|
||||
|
|
@ -480,14 +485,14 @@ public function handle(): void
|
|||
} catch (Throwable $e) {
|
||||
throw $e;
|
||||
} finally {
|
||||
if ($this->team) {
|
||||
BackupCreated::dispatch($this->team->id);
|
||||
}
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
}
|
||||
if ($this->team) {
|
||||
BackupCreated::dispatch($this->team->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Application\StopApplicationPreview;
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Actions\Shared\ComplexStatusCheck;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
|
|
@ -23,8 +26,10 @@
|
|||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
|
||||
use App\Notifications\Container\ContainerRestarted;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Services\RestartCountTracker;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
|
|
@ -95,8 +100,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
|
||||
public Collection $applicationContainerStatuses;
|
||||
|
||||
public Collection $applicationContainerRestartCounts;
|
||||
|
||||
public Collection $serviceContainerStatuses;
|
||||
|
||||
public Collection $previewContainerRestartCounts;
|
||||
|
||||
public Collection $serviceContainerRestartCounts;
|
||||
|
||||
public bool $foundProxy = false;
|
||||
|
||||
public bool $foundLogDrainContainer = false;
|
||||
|
|
@ -122,7 +133,10 @@ public function __construct(public Server $server, public $data)
|
|||
$this->foundApplicationPreviewsIds = collect();
|
||||
$this->foundServiceDatabaseIds = collect();
|
||||
$this->applicationContainerStatuses = collect();
|
||||
$this->applicationContainerRestartCounts = collect();
|
||||
$this->serviceContainerStatuses = collect();
|
||||
$this->previewContainerRestartCounts = collect();
|
||||
$this->serviceContainerRestartCounts = collect();
|
||||
$this->allApplicationIds = collect();
|
||||
$this->allDatabaseUuids = collect();
|
||||
$this->allTcpProxyUuids = collect();
|
||||
|
|
@ -140,7 +154,10 @@ public function handle()
|
|||
{
|
||||
// Defensive initialization for Collection properties to handle queue deserialization edge cases
|
||||
$this->serviceContainerStatuses ??= collect();
|
||||
$this->previewContainerRestartCounts ??= collect();
|
||||
$this->serviceContainerRestartCounts ??= collect();
|
||||
$this->applicationContainerStatuses ??= collect();
|
||||
$this->applicationContainerRestartCounts ??= collect();
|
||||
$this->foundApplicationIds ??= collect();
|
||||
$this->foundDatabaseUuids ??= collect();
|
||||
$this->foundServiceApplicationIds ??= collect();
|
||||
|
|
@ -231,6 +248,9 @@ public function handle()
|
|||
if (! $coolify_managed) {
|
||||
continue;
|
||||
}
|
||||
if (filter_var($labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = data_get($container, 'name');
|
||||
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
|
||||
|
|
@ -241,6 +261,10 @@ public function handle()
|
|||
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
|
||||
try {
|
||||
if ($pullRequestId === '0') {
|
||||
$application = $this->applicationsById->get((string) $applicationId);
|
||||
if ($application && $application->container_present !== true) {
|
||||
$application->update(['container_present' => true]);
|
||||
}
|
||||
if ($this->allApplicationIds->contains($applicationId)) {
|
||||
$this->foundApplicationIds->push($applicationId);
|
||||
}
|
||||
|
|
@ -251,6 +275,13 @@ public function handle()
|
|||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
$restartCount = data_get($container, 'restart_count');
|
||||
if (is_numeric($restartCount)) {
|
||||
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
|
||||
$this->applicationContainerRestartCounts->put($applicationId, collect());
|
||||
}
|
||||
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, (int) $restartCount);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$previewKey = $applicationId.':'.$pullRequestId;
|
||||
|
|
@ -258,6 +289,13 @@ public function handle()
|
|||
$this->foundApplicationPreviewsIds->push($previewKey);
|
||||
}
|
||||
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
|
||||
$restartCount = data_get($container, 'restart_count');
|
||||
if (is_numeric($restartCount)) {
|
||||
$this->previewContainerRestartCounts->push([
|
||||
'key' => $previewKey,
|
||||
'count' => (int) $restartCount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
|
|
@ -278,6 +316,7 @@ public function handle()
|
|||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
|
||||
$this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count'));
|
||||
}
|
||||
} elseif ($subType === 'database') {
|
||||
$this->foundServiceDatabaseIds->push($subId);
|
||||
|
|
@ -289,6 +328,7 @@ public function handle()
|
|||
$containerName = $labels->get('com.docker.compose.service');
|
||||
if ($containerName) {
|
||||
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
|
||||
$this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -302,9 +342,9 @@ public function handle()
|
|||
$this->foundDatabaseUuids->push($uuid);
|
||||
// TCP proxy should only be started/managed when database is actually running
|
||||
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: true);
|
||||
} else {
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
|
||||
$this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -317,6 +357,9 @@ public function handle()
|
|||
|
||||
$this->updateProxyStatus();
|
||||
|
||||
Application::whereIn('id', $this->foundApplicationIds->unique())
|
||||
->update(['container_present' => true]);
|
||||
|
||||
$this->updateNotFoundApplicationStatus();
|
||||
$this->updateNotFoundApplicationPreviewStatus();
|
||||
$this->updateNotFoundDatabaseStatus();
|
||||
|
|
@ -324,6 +367,8 @@ public function handle()
|
|||
|
||||
$this->updateAdditionalServersStatus();
|
||||
|
||||
$this->trackPreviewRestartCounts();
|
||||
|
||||
// Aggregate multi-container application statuses
|
||||
$this->aggregateMultiContainerStatuses();
|
||||
|
||||
|
|
@ -349,11 +394,18 @@ private function loadApplications(): Collection
|
|||
'uuid',
|
||||
'name',
|
||||
'status',
|
||||
'container_present',
|
||||
'build_pack',
|
||||
'docker_compose_raw',
|
||||
'environment_id',
|
||||
'destination_id',
|
||||
'destination_type',
|
||||
'last_online_at',
|
||||
'restart_count',
|
||||
'max_restart_count',
|
||||
'restart_limit_reached',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
])
|
||||
->withCount('additional_servers')
|
||||
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
|
||||
|
|
@ -372,11 +424,18 @@ private function loadApplications(): Collection
|
|||
'uuid',
|
||||
'name',
|
||||
'status',
|
||||
'container_present',
|
||||
'build_pack',
|
||||
'docker_compose_raw',
|
||||
'environment_id',
|
||||
'destination_id',
|
||||
'destination_type',
|
||||
'last_online_at',
|
||||
'restart_count',
|
||||
'max_restart_count',
|
||||
'restart_limit_reached',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
])
|
||||
->withCount('additional_servers')
|
||||
->whereIn('id', $additionalApplicationIds)
|
||||
|
|
@ -402,6 +461,11 @@ private function loadPreviews(): Collection
|
|||
'pull_request_id',
|
||||
'status',
|
||||
'last_online_at',
|
||||
'restart_count',
|
||||
'max_restart_count',
|
||||
'restart_limit_reached',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
])
|
||||
->whereIn('application_id', $applicationIds)
|
||||
->get();
|
||||
|
|
@ -417,7 +481,7 @@ private function loadServices(): Collection
|
|||
'docker_compose_raw',
|
||||
])
|
||||
->with([
|
||||
'applications:id,service_id,status,last_online_at',
|
||||
'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type',
|
||||
'databases:id,service_id,status,last_online_at,is_public,name',
|
||||
])
|
||||
->get();
|
||||
|
|
@ -495,6 +559,53 @@ private function aggregateMultiContainerStatuses()
|
|||
continue;
|
||||
}
|
||||
|
||||
$maxRestartCount = 0;
|
||||
$restartCountsAvailable = $this->applicationContainerRestartCounts->has($applicationId);
|
||||
if ($restartCountsAvailable) {
|
||||
$maxRestartCount = $this->applicationContainerRestartCounts->get($applicationId)->max() ?? 0;
|
||||
$restartState = (new RestartCountTracker)->evaluate(
|
||||
previousRestartCount: $application->restart_count ?? 0,
|
||||
observedRestartCount: $maxRestartCount,
|
||||
maxRestartCount: $application->max_restart_count ?? 0,
|
||||
);
|
||||
|
||||
if ($restartState['restart_count_changed']) {
|
||||
$hasCrashRestarts = $restartState['restart_count'] > 0;
|
||||
$application->update([
|
||||
'restart_count' => $restartState['restart_count'],
|
||||
'last_restart_at' => $hasCrashRestarts ? now() : null,
|
||||
'last_restart_type' => $hasCrashRestarts ? 'crash' : null,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($restartState['restart_limit_reached']) {
|
||||
$restartLimitClaimed = Application::query()
|
||||
->whereKey($application->getKey())
|
||||
->where('restart_limit_reached', false)
|
||||
->update(['restart_limit_reached' => true]) === 1;
|
||||
|
||||
if ($restartLimitClaimed) {
|
||||
$application->refresh();
|
||||
StopApplication::dispatch(
|
||||
application: $application,
|
||||
previewDeployments: false,
|
||||
dockerCleanup: false,
|
||||
resetRestartCount: false,
|
||||
removeContainers: false,
|
||||
);
|
||||
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($application->stoppedAfterRestartLimit() && $containerStatuses->every(
|
||||
fn (string $status): bool => str($status)->contains('exited')
|
||||
)) {
|
||||
$application->update(['status' => 'exited']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
|
@ -519,7 +630,7 @@ private function aggregateMultiContainerStatuses()
|
|||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true);
|
||||
|
||||
// Update application status with aggregated result
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
|
|
@ -560,6 +671,14 @@ private function aggregateServiceContainerStatuses()
|
|||
continue;
|
||||
}
|
||||
|
||||
$restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0;
|
||||
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
|
||||
StopServiceApplication::dispatch($subResource, false, false);
|
||||
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse docker compose from service to check for excluded containers
|
||||
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
|
||||
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
|
||||
|
|
@ -581,10 +700,9 @@ private function aggregateServiceContainerStatuses()
|
|||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
|
||||
// Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded"
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $restartCount, preserveRestarting: true);
|
||||
|
||||
// Update service sub-resource status with aggregated result
|
||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
|
|
@ -627,8 +745,11 @@ private function updateNotFoundApplicationStatus()
|
|||
|
||||
// Batch update: mark all not-found applications as exited (excluding already exited ones)
|
||||
Application::whereIn('id', $notFoundApplicationIds)
|
||||
->where('status', 'not like', 'exited%')
|
||||
->update(['status' => 'exited']);
|
||||
->update([
|
||||
'status' => 'exited',
|
||||
'container_present' => false,
|
||||
'restart_limit_reached' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function updateNotFoundApplicationPreviewStatus()
|
||||
|
|
@ -687,7 +808,7 @@ private function updateProxyStatus()
|
|||
}
|
||||
}
|
||||
|
||||
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
|
||||
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, mixed $restartCount = null, bool $tcpProxy = false): void
|
||||
{
|
||||
$database = $this->databasesByUuid->get($databaseUuid);
|
||||
if (! $database) {
|
||||
|
|
@ -697,6 +818,13 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
|
|||
$database->status = $containerStatus;
|
||||
$database->save();
|
||||
}
|
||||
if (is_numeric($restartCount) && $restartCount > ($database->restart_count ?? 0)) {
|
||||
$database->update([
|
||||
'restart_count' => (int) $restartCount,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'crash',
|
||||
]);
|
||||
}
|
||||
if (! $this->isCompleteSnapshot()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -719,6 +847,30 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
|
|||
}
|
||||
}
|
||||
|
||||
private function storeServiceRestartCount(string $key, string $containerName, mixed $restartCount): void
|
||||
{
|
||||
if (! is_numeric($restartCount)) {
|
||||
return;
|
||||
}
|
||||
if (! $this->serviceContainerRestartCounts->has($key)) {
|
||||
$this->serviceContainerRestartCounts->put($key, collect());
|
||||
}
|
||||
$this->serviceContainerRestartCounts->get($key)->put($containerName, (int) $restartCount);
|
||||
}
|
||||
|
||||
private function trackPreviewRestartCounts(): void
|
||||
{
|
||||
$this->previewContainerRestartCounts
|
||||
->groupBy('key')
|
||||
->each(function (Collection $counts, string $key): void {
|
||||
$preview = $this->previewsByKey->get($key);
|
||||
if ($preview?->trackRestartCount((int) $counts->max('count'))) {
|
||||
StopApplicationPreview::dispatch($preview, false, false);
|
||||
$preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function updateNotFoundDatabaseStatus()
|
||||
{
|
||||
$notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids);
|
||||
|
|
@ -752,8 +904,9 @@ private function updateNotFoundServiceStatus()
|
|||
// Batch update service applications
|
||||
if ($notFoundServiceApplicationIds->isNotEmpty()) {
|
||||
ServiceApplication::whereIn('id', $notFoundServiceApplicationIds)
|
||||
->where('restart_limit_reached', false)
|
||||
->where('status', '!=', 'exited')
|
||||
->update(['status' => 'exited']);
|
||||
->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]);
|
||||
}
|
||||
|
||||
// Batch update service databases
|
||||
|
|
|
|||
|
|
@ -66,7 +66,10 @@ public function handle()
|
|||
caCert: $caCert->ssl_certificate,
|
||||
caKey: $caCert->ssl_private_key,
|
||||
);
|
||||
$regenerated->push($certificate);
|
||||
$resource = $certificate->database;
|
||||
if ($resource) {
|
||||
$regenerated->push($resource);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to regenerate SSL certificate: '.$e->getMessage());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public function mount(string $destination_uuid)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -85,7 +85,7 @@ public function delete()
|
|||
}
|
||||
$this->destination->delete();
|
||||
|
||||
return redirect()->route('destination.index');
|
||||
return redirectRoute($this, 'destination.index');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1507,8 +1507,7 @@ public function getServicesProperty()
|
|||
'type' => 'one-click-service-'.$serviceKey,
|
||||
'category' => 'Services',
|
||||
'resourceType' => 'service',
|
||||
'logo' => data_get($service, 'logo'),
|
||||
] + array_filter([
|
||||
] + service_logo_urls(data_get($service, 'logo')) + array_filter([
|
||||
'amd_only' => data_get($service, 'amd_only') ? true : null,
|
||||
'arm_only' => data_get($service, 'arm_only') ? true : null,
|
||||
]));
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ class Discord extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $statusChangeDiscordNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $restartLimitReachedDiscordNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $backupSuccessDiscordNotifications = false;
|
||||
|
||||
|
|
@ -82,17 +85,17 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->discord_enabled = $this->discordEnabled;
|
||||
$this->settings->discord_webhook_url = $this->discordWebhookUrl;
|
||||
|
||||
$this->settings->deployment_success_discord_notifications = $this->deploymentSuccessDiscordNotifications;
|
||||
$this->settings->deployment_failure_discord_notifications = $this->deploymentFailureDiscordNotifications;
|
||||
$this->settings->status_change_discord_notifications = $this->statusChangeDiscordNotifications;
|
||||
$this->settings->restart_limit_reached_discord_notifications = $this->restartLimitReachedDiscordNotifications;
|
||||
$this->settings->backup_success_discord_notifications = $this->backupSuccessDiscordNotifications;
|
||||
$this->settings->backup_failure_discord_notifications = $this->backupFailureDiscordNotifications;
|
||||
$this->settings->scheduled_task_success_discord_notifications = $this->scheduledTaskSuccessDiscordNotifications;
|
||||
|
|
@ -118,6 +121,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications;
|
||||
$this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications;
|
||||
$this->statusChangeDiscordNotifications = $this->settings->status_change_discord_notifications;
|
||||
$this->restartLimitReachedDiscordNotifications = $this->settings->restart_limit_reached_discord_notifications;
|
||||
$this->backupSuccessDiscordNotifications = $this->settings->backup_success_discord_notifications;
|
||||
$this->backupFailureDiscordNotifications = $this->settings->backup_failure_discord_notifications;
|
||||
$this->scheduledTaskSuccessDiscordNotifications = $this->settings->scheduled_task_success_discord_notifications;
|
||||
|
|
@ -169,6 +173,7 @@ public function instantSaveDiscordEnabled()
|
|||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -179,6 +184,7 @@ public function submit()
|
|||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -188,6 +194,8 @@ public function submit()
|
|||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
$this->syncData(true);
|
||||
refreshSession();
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
|
|
|
|||
|
|
@ -79,6 +79,9 @@ class Email extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $statusChangeEmailNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $restartLimitReachedEmailNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $backupSuccessEmailNotifications = false;
|
||||
|
||||
|
|
@ -129,12 +132,11 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled;
|
||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||
$this->settings->smtp_from_name = $this->smtpFromName;
|
||||
|
|
@ -155,6 +157,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications;
|
||||
$this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications;
|
||||
$this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications;
|
||||
$this->settings->restart_limit_reached_email_notifications = $this->restartLimitReachedEmailNotifications;
|
||||
$this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications;
|
||||
$this->settings->backup_failure_email_notifications = $this->backupFailureEmailNotifications;
|
||||
$this->settings->scheduled_task_success_email_notifications = $this->scheduledTaskSuccessEmailNotifications;
|
||||
|
|
@ -193,6 +196,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications;
|
||||
$this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications;
|
||||
$this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications;
|
||||
$this->restartLimitReachedEmailNotifications = $this->settings->restart_limit_reached_email_notifications;
|
||||
$this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications;
|
||||
$this->backupFailureEmailNotifications = $this->settings->backup_failure_email_notifications;
|
||||
$this->scheduledTaskSuccessEmailNotifications = $this->settings->scheduled_task_success_email_notifications;
|
||||
|
|
@ -219,6 +223,8 @@ public function submit()
|
|||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Email notifications settings updated.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ class Pushover extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $statusChangePushoverNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $restartLimitReachedPushoverNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $backupSuccessPushoverNotifications = false;
|
||||
|
||||
|
|
@ -86,11 +89,10 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->pushover_enabled = $this->pushoverEnabled;
|
||||
$this->settings->pushover_user_key = $this->pushoverUserKey;
|
||||
$this->settings->pushover_api_token = $this->pushoverApiToken;
|
||||
|
|
@ -98,6 +100,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->deployment_success_pushover_notifications = $this->deploymentSuccessPushoverNotifications;
|
||||
$this->settings->deployment_failure_pushover_notifications = $this->deploymentFailurePushoverNotifications;
|
||||
$this->settings->status_change_pushover_notifications = $this->statusChangePushoverNotifications;
|
||||
$this->settings->restart_limit_reached_pushover_notifications = $this->restartLimitReachedPushoverNotifications;
|
||||
$this->settings->backup_success_pushover_notifications = $this->backupSuccessPushoverNotifications;
|
||||
$this->settings->backup_failure_pushover_notifications = $this->backupFailurePushoverNotifications;
|
||||
$this->settings->scheduled_task_success_pushover_notifications = $this->scheduledTaskSuccessPushoverNotifications;
|
||||
|
|
@ -125,6 +128,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications;
|
||||
$this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications;
|
||||
$this->statusChangePushoverNotifications = $this->settings->status_change_pushover_notifications;
|
||||
$this->restartLimitReachedPushoverNotifications = $this->settings->restart_limit_reached_pushover_notifications;
|
||||
$this->backupSuccessPushoverNotifications = $this->settings->backup_success_pushover_notifications;
|
||||
$this->backupFailurePushoverNotifications = $this->settings->backup_failure_pushover_notifications;
|
||||
$this->scheduledTaskSuccessPushoverNotifications = $this->settings->scheduled_task_success_pushover_notifications;
|
||||
|
|
@ -162,6 +166,7 @@ public function instantSavePushoverEnabled()
|
|||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -174,6 +179,7 @@ public function submit()
|
|||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -183,6 +189,8 @@ public function submit()
|
|||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
$this->syncData(true);
|
||||
refreshSession();
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ class Slack extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $statusChangeSlackNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $restartLimitReachedSlackNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $backupSuccessSlackNotifications = false;
|
||||
|
||||
|
|
@ -84,17 +87,17 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->slack_enabled = $this->slackEnabled;
|
||||
$this->settings->slack_webhook_url = $this->slackWebhookUrl;
|
||||
|
||||
$this->settings->deployment_success_slack_notifications = $this->deploymentSuccessSlackNotifications;
|
||||
$this->settings->deployment_failure_slack_notifications = $this->deploymentFailureSlackNotifications;
|
||||
$this->settings->status_change_slack_notifications = $this->statusChangeSlackNotifications;
|
||||
$this->settings->restart_limit_reached_slack_notifications = $this->restartLimitReachedSlackNotifications;
|
||||
$this->settings->backup_success_slack_notifications = $this->backupSuccessSlackNotifications;
|
||||
$this->settings->backup_failure_slack_notifications = $this->backupFailureSlackNotifications;
|
||||
$this->settings->scheduled_task_success_slack_notifications = $this->scheduledTaskSuccessSlackNotifications;
|
||||
|
|
@ -118,6 +121,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications;
|
||||
$this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications;
|
||||
$this->statusChangeSlackNotifications = $this->settings->status_change_slack_notifications;
|
||||
$this->restartLimitReachedSlackNotifications = $this->settings->restart_limit_reached_slack_notifications;
|
||||
$this->backupSuccessSlackNotifications = $this->settings->backup_success_slack_notifications;
|
||||
$this->backupFailureSlackNotifications = $this->settings->backup_failure_slack_notifications;
|
||||
$this->scheduledTaskSuccessSlackNotifications = $this->settings->scheduled_task_success_slack_notifications;
|
||||
|
|
@ -153,6 +157,7 @@ public function instantSaveSlackEnabled()
|
|||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -165,6 +170,7 @@ public function submit()
|
|||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -174,6 +180,8 @@ public function submit()
|
|||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
$this->syncData(true);
|
||||
refreshSession();
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ class Telegram extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $statusChangeTelegramNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $restartLimitReachedTelegramNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $backupSuccessTelegramNotifications = false;
|
||||
|
||||
|
|
@ -83,6 +86,9 @@ class Telegram extends Component
|
|||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsStatusChangeThreadId = null;
|
||||
|
||||
#[Validate(['nullable', 'string', 'max:255'])]
|
||||
public ?string $telegramNotificationsRestartLimitReachedThreadId = null;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $telegramNotificationsBackupSuccessThreadId = null;
|
||||
|
||||
|
|
@ -128,11 +134,10 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->telegram_enabled = $this->telegramEnabled;
|
||||
$this->settings->telegram_token = $this->telegramToken;
|
||||
$this->settings->telegram_chat_id = $this->telegramChatId;
|
||||
|
|
@ -140,6 +145,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->deployment_success_telegram_notifications = $this->deploymentSuccessTelegramNotifications;
|
||||
$this->settings->deployment_failure_telegram_notifications = $this->deploymentFailureTelegramNotifications;
|
||||
$this->settings->status_change_telegram_notifications = $this->statusChangeTelegramNotifications;
|
||||
$this->settings->restart_limit_reached_telegram_notifications = $this->restartLimitReachedTelegramNotifications;
|
||||
$this->settings->backup_success_telegram_notifications = $this->backupSuccessTelegramNotifications;
|
||||
$this->settings->backup_failure_telegram_notifications = $this->backupFailureTelegramNotifications;
|
||||
$this->settings->scheduled_task_success_telegram_notifications = $this->scheduledTaskSuccessTelegramNotifications;
|
||||
|
|
@ -155,6 +161,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
|
||||
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
|
||||
$this->settings->telegram_notifications_status_change_thread_id = $this->telegramNotificationsStatusChangeThreadId;
|
||||
$this->settings->telegram_notifications_restart_limit_reached_thread_id = $this->telegramNotificationsRestartLimitReachedThreadId;
|
||||
$this->settings->telegram_notifications_backup_success_thread_id = $this->telegramNotificationsBackupSuccessThreadId;
|
||||
$this->settings->telegram_notifications_backup_failure_thread_id = $this->telegramNotificationsBackupFailureThreadId;
|
||||
$this->settings->telegram_notifications_scheduled_task_success_thread_id = $this->telegramNotificationsScheduledTaskSuccessThreadId;
|
||||
|
|
@ -173,6 +180,21 @@ public function syncData(bool $toModel = false)
|
|||
if (auth()->user()->can('update', $this->settings)) {
|
||||
$this->telegramToken = $this->settings->telegram_token;
|
||||
$this->telegramChatId = $this->settings->telegram_chat_id;
|
||||
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
|
||||
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
|
||||
$this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id;
|
||||
$this->telegramNotificationsRestartLimitReachedThreadId = $this->settings->telegram_notifications_restart_limit_reached_thread_id;
|
||||
$this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id;
|
||||
$this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id;
|
||||
$this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id;
|
||||
$this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id;
|
||||
$this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id;
|
||||
$this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id;
|
||||
$this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id;
|
||||
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
|
||||
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
|
||||
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
|
||||
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
|
||||
} else {
|
||||
$this->telegramToken = null;
|
||||
$this->telegramChatId = null;
|
||||
|
|
@ -181,6 +203,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications;
|
||||
$this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications;
|
||||
$this->statusChangeTelegramNotifications = $this->settings->status_change_telegram_notifications;
|
||||
$this->restartLimitReachedTelegramNotifications = $this->settings->restart_limit_reached_telegram_notifications;
|
||||
$this->backupSuccessTelegramNotifications = $this->settings->backup_success_telegram_notifications;
|
||||
$this->backupFailureTelegramNotifications = $this->settings->backup_failure_telegram_notifications;
|
||||
$this->scheduledTaskSuccessTelegramNotifications = $this->settings->scheduled_task_success_telegram_notifications;
|
||||
|
|
@ -193,26 +216,13 @@ public function syncData(bool $toModel = false)
|
|||
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
|
||||
$this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications;
|
||||
|
||||
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
|
||||
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
|
||||
$this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id;
|
||||
$this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id;
|
||||
$this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id;
|
||||
$this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id;
|
||||
$this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id;
|
||||
$this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id;
|
||||
$this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id;
|
||||
$this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id;
|
||||
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
|
||||
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
|
||||
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
|
||||
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -225,6 +235,7 @@ public function submit()
|
|||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -254,6 +265,8 @@ public function instantSaveTelegramEnabled()
|
|||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
$this->syncData(true);
|
||||
refreshSession();
|
||||
$this->dispatch('success', 'Settings saved.');
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ class Webhook extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $statusChangeWebhookNotifications = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $restartLimitReachedWebhookNotifications = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $backupSuccessWebhookNotifications = false;
|
||||
|
||||
|
|
@ -79,17 +82,17 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->webhook_enabled = $this->webhookEnabled;
|
||||
$this->settings->webhook_url = $this->webhookUrl;
|
||||
|
||||
$this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications;
|
||||
$this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications;
|
||||
$this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications;
|
||||
$this->settings->restart_limit_reached_webhook_notifications = $this->restartLimitReachedWebhookNotifications;
|
||||
$this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications;
|
||||
$this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications;
|
||||
$this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications;
|
||||
|
|
@ -113,6 +116,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
|
||||
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
|
||||
$this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications;
|
||||
$this->restartLimitReachedWebhookNotifications = $this->settings->restart_limit_reached_webhook_notifications;
|
||||
$this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications;
|
||||
$this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications;
|
||||
$this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications;
|
||||
|
|
@ -147,6 +151,7 @@ public function instantSaveWebhookEnabled()
|
|||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -157,6 +162,7 @@ public function submit()
|
|||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->syncData(true);
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -166,6 +172,8 @@ public function submit()
|
|||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
$this->syncData(true);
|
||||
refreshSession();
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ public function uploadAvatar(AvatarStorageService $avatarStorage): bool
|
|||
|
||||
$avatarStorage->store(Auth::user(), $this->avatar);
|
||||
$this->reset('avatar');
|
||||
$this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp]));
|
||||
$this->dispatch('avatar-updated', url: profile_avatar_url(Auth::user()->fresh()));
|
||||
$this->dispatch('success', 'Profile picture updated.');
|
||||
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
|
@ -57,7 +58,7 @@ class Domains extends Component
|
|||
|
||||
public ?string $editingService = null;
|
||||
|
||||
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */
|
||||
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool, internal_port?: ?int, has_port_override?: bool}> */
|
||||
public array $domainRows = [];
|
||||
|
||||
/** When set, the next addSuggestedDomain call for this index skips the DNS block. */
|
||||
|
|
@ -70,6 +71,14 @@ class Domains extends Component
|
|||
|
||||
public bool $showDomainConflictModal = false;
|
||||
|
||||
public bool $showPortWarningModal = false;
|
||||
|
||||
public bool $forceUseUnknownPort = false;
|
||||
|
||||
public ?int $unrecognizedPort = null;
|
||||
|
||||
public ?string $pendingPortAction = null;
|
||||
|
||||
public bool $forceSaveDomains = false;
|
||||
|
||||
public bool $forceSaveDns = false;
|
||||
|
|
@ -485,32 +494,19 @@ protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOv
|
|||
|
||||
/**
|
||||
* @param array<string, array{status?: string, message?: string, expected_ip?: ?string, checked_at?: ?string}> $stored
|
||||
* @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool}
|
||||
* @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool, internal_port: ?int, has_port_override: bool}
|
||||
*/
|
||||
protected function domainRowFromStored(string $url, ?string $service, array $stored): array
|
||||
{
|
||||
$key = $this->domainDnsStatusKey($url, $service);
|
||||
$entry = $stored[$key] ?? null;
|
||||
$port = $this->effectiveDomainInternalPort($url, $service);
|
||||
|
||||
if (is_array($entry) && filled(data_get($entry, 'status'))) {
|
||||
return [
|
||||
'url' => $url,
|
||||
'service' => $service,
|
||||
'dns_status' => (string) data_get($entry, 'status', 'pending'),
|
||||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
'needs_force_add' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
$row = [
|
||||
'url' => $url,
|
||||
'service' => $service,
|
||||
'internal_port' => $port['internal_port'],
|
||||
'has_port_override' => $port['has_port_override'],
|
||||
'dns_status' => 'pending',
|
||||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
|
|
@ -521,6 +517,112 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto
|
|||
'suggestion_label' => null,
|
||||
'needs_force_add' => false,
|
||||
];
|
||||
|
||||
if (is_array($entry) && filled(data_get($entry, 'status'))) {
|
||||
$row['dns_status'] = (string) data_get($entry, 'status', 'pending');
|
||||
$row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.');
|
||||
$row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp;
|
||||
$row['checked_at'] = data_get($entry, 'checked_at');
|
||||
$row['check_id'] = data_get($entry, 'check_id');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{internal_port: ?int, has_port_override: bool}
|
||||
*/
|
||||
protected function effectiveDomainInternalPort(string $url, ?string $service = null): array
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$overrides = $this->application->domain_port_overrides ?? [];
|
||||
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
|
||||
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
|
||||
$hasMapEntry = array_key_exists($canonical, $overrides);
|
||||
|
||||
if ($hasMapEntry) {
|
||||
return [
|
||||
'internal_port' => (int) $overrides[$canonical],
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($legacyPort !== null) {
|
||||
return [
|
||||
'internal_port' => $legacyPort,
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->application->settings?->is_static) {
|
||||
return [
|
||||
'internal_port' => 80,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service);
|
||||
if ($composePort !== null) {
|
||||
return [
|
||||
'internal_port' => $composePort,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$exposed = $this->application->ports_exposes_array;
|
||||
$defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0
|
||||
? (int) $exposed[0]
|
||||
: null;
|
||||
|
||||
return [
|
||||
'internal_port' => $defaultPort,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{scheme: string, host: string, port: string, path: string} $parts
|
||||
*/
|
||||
protected function portFromParts(array $parts): ?int
|
||||
{
|
||||
$port = trim((string) ($parts['port'] ?? ''));
|
||||
if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $port;
|
||||
}
|
||||
|
||||
protected function currentRowPort(string $url): ?int
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$override = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($override) && (int) $override > 0) {
|
||||
return (int) $override;
|
||||
}
|
||||
|
||||
$legacy = DomainUrlParts::split($url)['port'] ?? '';
|
||||
|
||||
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
|
||||
}
|
||||
|
||||
protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
|
||||
{
|
||||
if ($this->forceUseUnknownPort || $port === null) {
|
||||
return false;
|
||||
}
|
||||
if ($currentPort !== null && $port === $currentPort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->application->portRequiresConfirmation($port);
|
||||
}
|
||||
|
||||
protected function openPortWarning(?int $port, string $action): void
|
||||
{
|
||||
$this->unrecognizedPort = $port;
|
||||
$this->pendingPortAction = $action;
|
||||
$this->showPortWarningModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -824,6 +926,31 @@ public function confirmDomainUsage(): void
|
|||
$this->addDomain();
|
||||
}
|
||||
|
||||
public function confirmUseUnknownPort(): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
$this->forceUseUnknownPort = true;
|
||||
$this->showPortWarningModal = false;
|
||||
$action = $this->pendingPortAction;
|
||||
$this->pendingPortAction = null;
|
||||
|
||||
if ($action === 'update') {
|
||||
$this->updateDomain();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addDomain();
|
||||
}
|
||||
|
||||
public function cancelUseUnknownPort(): void
|
||||
{
|
||||
$this->showPortWarningModal = false;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->unrecognizedPort = null;
|
||||
$this->pendingPortAction = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending conflict state when the modal is dismissed without confirmation.
|
||||
* confirmDomainUsage sets forceSaveDomains before closing the modal.
|
||||
|
|
@ -848,7 +975,7 @@ public function addDomain(): void
|
|||
return;
|
||||
}
|
||||
|
||||
if ($this->newDomainPartsChanged) {
|
||||
if ($this->newDomainPartsChanged || filled($this->newDomainParts['host'] ?? null)) {
|
||||
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
|
||||
}
|
||||
$this->validateOnly('newDomain');
|
||||
|
|
@ -867,15 +994,24 @@ public function addDomain(): void
|
|||
->values()
|
||||
->all();
|
||||
$current = $this->currentDomainList($this->newDomainService);
|
||||
$currentCanonicalDomains = $current->map(
|
||||
fn (string $url): string => DomainPortOverrides::withoutPort($url)
|
||||
);
|
||||
|
||||
foreach ($newUrls as $url) {
|
||||
if ($current->contains($url)) {
|
||||
if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) {
|
||||
$this->addError('newDomain', "Domain {$url} is already configured.");
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) {
|
||||
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
|
||||
$this->pendingAction = 'add';
|
||||
if (! $this->saveDomainList($merged, $this->newDomainService)) {
|
||||
|
|
@ -884,6 +1020,7 @@ public function addDomain(): void
|
|||
|
||||
$this->forceSaveDomains = false;
|
||||
$this->pendingAction = null;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$serviceForCheck = $this->newDomainService;
|
||||
$this->resetAddDomainForm();
|
||||
$this->dispatch('close-modal');
|
||||
|
|
@ -1007,6 +1144,7 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void
|
|||
$skipDns = ! $this->dnsValidationEnabled
|
||||
|| ! $server
|
||||
|| $this->application->additional_servers->count() > 0;
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
$url = $row['url'] ?? null;
|
||||
|
|
@ -1109,6 +1247,11 @@ public function startEdit(int $index): void
|
|||
$this->editingIndex = $index;
|
||||
$this->editingDomain = $this->domainRows[$index]['url'];
|
||||
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
|
||||
$canonical = DomainPortOverrides::withoutPort($this->editingDomain);
|
||||
$savedPort = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($savedPort)) {
|
||||
$this->editingDomainParts['port'] = (string) $savedPort;
|
||||
}
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingService = $this->domainRows[$index]['service'];
|
||||
$this->resetEditDomainDnsGate();
|
||||
|
|
@ -1226,7 +1369,7 @@ public function updateDomain(): void
|
|||
return;
|
||||
}
|
||||
|
||||
if ($this->editingDomainPartsChanged) {
|
||||
if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) {
|
||||
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
|
||||
}
|
||||
$this->validateOnly('editingDomain');
|
||||
|
|
@ -1243,13 +1386,29 @@ public function updateDomain(): void
|
|||
$service = $this->editingService;
|
||||
$wasNoindexed = $this->application->isDomainNoindexed($oldUrl);
|
||||
|
||||
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
|
||||
$portOverrides = $this->application->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]);
|
||||
$this->application->domain_port_overrides = $portOverrides ?: null;
|
||||
}
|
||||
|
||||
$current = $this->currentDomainList($service);
|
||||
if ($newUrl !== $oldUrl && $current->contains($newUrl)) {
|
||||
$otherCanonicalDomains = $current
|
||||
->reject(fn (string $url): bool => $url === $oldUrl)
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url));
|
||||
if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) {
|
||||
$this->addError('editingDomain', "Domain {$newUrl} is already configured.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) {
|
||||
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
|
||||
if ($dnsFailure !== null) {
|
||||
|
|
@ -1277,6 +1436,7 @@ public function updateDomain(): void
|
|||
|
||||
$this->forceSaveDomains = false;
|
||||
$this->pendingAction = null;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->cancelEdit();
|
||||
$this->dispatch('edit-domain-saved');
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
|
|
@ -1322,6 +1482,28 @@ public function removeDomain(int $index): void
|
|||
}
|
||||
}
|
||||
|
||||
public function removeDomainByKey(string $domainKey): void
|
||||
{
|
||||
$index = collect($this->domainRows)->search(
|
||||
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
|
||||
&& hash_equals($domainKey, $this->domainRowKey($row))
|
||||
);
|
||||
|
||||
if ($index === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removeDomain((int) $index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{url: string, service?: ?string} $row
|
||||
*/
|
||||
private function domainRowKey(array $row): string
|
||||
{
|
||||
return hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
|
||||
}
|
||||
|
||||
public function generateDomain(?string $serviceName = null): void
|
||||
{
|
||||
try {
|
||||
|
|
@ -1800,6 +1982,8 @@ protected function saveDomainList(
|
|||
}
|
||||
}
|
||||
|
||||
$intendedComposeOverrides = null;
|
||||
|
||||
if ($this->isCompose) {
|
||||
if (blank($serviceName)) {
|
||||
$this->dispatch('error', 'A service is required for compose domains.');
|
||||
|
|
@ -1815,6 +1999,15 @@ protected function saveDomainList(
|
|||
$allDomains = [];
|
||||
}
|
||||
|
||||
$previousServiceUrls = $this->currentDomainList($serviceName);
|
||||
$normalizedPorts = DomainPortOverrides::normalize($domainString, $this->application->domain_port_overrides);
|
||||
$domainString = $normalizedPorts['fqdn'];
|
||||
$intendedComposeOverrides = $this->mergeComposeDomainPortOverrides(
|
||||
$previousServiceUrls,
|
||||
$domainString,
|
||||
$normalizedPorts['overrides'] ?? null,
|
||||
);
|
||||
|
||||
$existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : [];
|
||||
// Preserve stored redirect only — pending Direction dropdown values must not
|
||||
// persist until setServiceRedirect() runs.
|
||||
|
|
@ -1823,6 +2016,7 @@ protected function saveDomainList(
|
|||
]);
|
||||
|
||||
$this->application->docker_compose_domains = json_encode($allDomains);
|
||||
$this->application->domain_port_overrides = $intendedComposeOverrides;
|
||||
$this->application->fqdn = null;
|
||||
} else {
|
||||
$this->application->fqdn = $domainString;
|
||||
|
|
@ -1849,12 +2043,47 @@ protected function saveDomainList(
|
|||
}
|
||||
|
||||
$this->application->save();
|
||||
|
||||
if ($this->isCompose && ($this->application->domain_port_overrides ?? null) !== $intendedComposeOverrides) {
|
||||
$this->application->domain_port_overrides = $intendedComposeOverrides;
|
||||
$this->application->save();
|
||||
}
|
||||
|
||||
$this->resetDefaultLabels();
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, string> $previousServiceUrls
|
||||
* @param array<string, int>|null $incomingOverrides
|
||||
* @return array<string, int>|null
|
||||
*/
|
||||
protected function mergeComposeDomainPortOverrides(
|
||||
Collection $previousServiceUrls,
|
||||
?string $newDomainString,
|
||||
?array $incomingOverrides,
|
||||
): ?array {
|
||||
$merged = $this->application->domain_port_overrides ?? [];
|
||||
$newCanonical = collect($this->splitDomains($newDomainString))
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
|
||||
->all();
|
||||
|
||||
foreach ($previousServiceUrls as $url) {
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
if (! in_array($canonical, $newCanonical, true)) {
|
||||
unset($merged[$canonical]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($incomingOverrides ?? [] as $url => $port) {
|
||||
$merged[$url] = (int) $port;
|
||||
}
|
||||
|
||||
return $merged ?: null;
|
||||
}
|
||||
|
||||
protected function resetDefaultLabels(): void
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Application\GenerateConfig;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Livewire\Project\Service\Storage;
|
||||
use App\Models\Application;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
|
@ -320,17 +321,6 @@ public function mount()
|
|||
}
|
||||
}
|
||||
$this->initialDockerComposeLocation = $this->application->docker_compose_location;
|
||||
if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) {
|
||||
// Only load compose file if user has update permission
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$this->initLoadingCompose = true;
|
||||
$this->dispatch('info', 'Loading docker compose file.');
|
||||
} catch (AuthorizationException $e) {
|
||||
// User doesn't have update permission, skip loading compose file
|
||||
}
|
||||
}
|
||||
|
||||
if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) {
|
||||
$this->dispatch('configurationChanged');
|
||||
}
|
||||
|
|
@ -340,7 +330,7 @@ public function mount()
|
|||
$this->syncData();
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -530,7 +520,7 @@ public function loadComposeFile($isInit = false, $showToast = true, ?string $res
|
|||
|
||||
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
|
||||
$this->dispatch('compose_loaded');
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('storageCountsChanged')->to(Storage::class);
|
||||
$this->dispatch('refreshEnvs');
|
||||
} catch (\Throwable $e) {
|
||||
// Refresh model to get restored values from Application::loadComposeFile
|
||||
|
|
@ -607,14 +597,9 @@ public function updatedBuildPack()
|
|||
$this->resetDefaultLabels(false);
|
||||
}
|
||||
if ($this->buildPack === 'dockercompose') {
|
||||
// Only update if user has permission
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$this->fqdn = null;
|
||||
$this->application->fqdn = null;
|
||||
$this->application->settings->save();
|
||||
} catch (AuthorizationException $e) {
|
||||
// User doesn't have update permission, just continue without saving
|
||||
if (blank($this->dockerComposeLocation)) {
|
||||
$this->dockerComposeLocation = '/docker-compose.yaml';
|
||||
$this->application->docker_compose_location = $this->dockerComposeLocation;
|
||||
}
|
||||
}
|
||||
if ($this->buildPack === 'static') {
|
||||
|
|
@ -666,6 +651,8 @@ public function generateNginxConfiguration($type = 'static')
|
|||
|
||||
public function resetDefaultLabels($manualReset = false)
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
try {
|
||||
if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) {
|
||||
return;
|
||||
|
|
|
|||
617
app/Livewire/Project/Application/PreviewDomains.php
Normal file
617
app/Livewire/Project/Application/PreviewDomains.php
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class PreviewDomains extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public ApplicationPreview $preview;
|
||||
|
||||
public array $domainRows = [];
|
||||
|
||||
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
|
||||
|
||||
public ?string $newDomainService = null;
|
||||
|
||||
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
|
||||
|
||||
public ?int $editingIndex = null;
|
||||
|
||||
public bool $showPortWarningModal = false;
|
||||
|
||||
public bool $forceUseUnknownPort = false;
|
||||
|
||||
public ?int $unrecognizedPort = null;
|
||||
|
||||
public ?string $pendingPortAction = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refreshDomains();
|
||||
if ($this->preview->application->build_pack === 'dockercompose') {
|
||||
$this->newDomainService = $this->composeServices()[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.preview-domains', [
|
||||
'isCompose' => $this->preview->application->build_pack === 'dockercompose',
|
||||
'composeServices' => $this->composeServices(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function addDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
if ($this->preview->application->build_pack === 'dockercompose'
|
||||
&& ($this->newDomainService === null || ! in_array($this->newDomainService, $this->composeServices(), true))) {
|
||||
$this->addError('newDomainService', 'Select a valid Compose service.');
|
||||
|
||||
return;
|
||||
}
|
||||
$domain = $this->validatedDomain($this->newDomainParts, 'newDomainParts.host');
|
||||
if ($domain === null) {
|
||||
return;
|
||||
}
|
||||
$canonicalDomain = DomainPortOverrides::withoutPort($domain);
|
||||
if (collect($this->domainRows)->contains(
|
||||
fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === $canonicalDomain
|
||||
&& $row['service'] === $this->newDomainService
|
||||
)) {
|
||||
$this->addError('newDomainParts.host', 'This domain is already configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) {
|
||||
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->domainRows[] = $this->makeRow($domain, $this->newDomainService);
|
||||
$index = array_key_last($this->domainRows);
|
||||
$checkId = new_public_id();
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$domain = $this->domainRows[$index]['url'] ?? DomainPortOverrides::withoutPort($domain);
|
||||
$this->newDomainParts = DomainUrlParts::empty();
|
||||
$this->newDomainService = $this->preview->application->build_pack === 'dockercompose'
|
||||
? ($this->composeServices()[0] ?? null)
|
||||
: null;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->dispatch('close-modal');
|
||||
|
||||
try {
|
||||
$server = $this->preview->application->destination?->server;
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->preview,
|
||||
$this->statusKey($domain, $this->domainRows[$index]['service']),
|
||||
$domain,
|
||||
$server,
|
||||
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
|
||||
$checkId,
|
||||
$this->preview->application->additional_servers->count() > 0,
|
||||
);
|
||||
$this->dispatch('success', 'Domain added. DNS check started.');
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
$this->persistDnsStatuses();
|
||||
$this->dispatch('error', 'Domain added, but the DNS check could not be started. Try again from the preview domains list.');
|
||||
}
|
||||
}
|
||||
|
||||
public function generateDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->preview->refresh();
|
||||
if ($this->preview->application->build_pack === 'dockercompose') {
|
||||
if ($this->newDomainService === null && $this->domainRows === []) {
|
||||
$this->preview->generate_preview_fqdn_compose(generateWithoutApplicationDomain: true);
|
||||
} else {
|
||||
$service = $this->newDomainService ?? data_get($this->domainRows, '0.service');
|
||||
foreach ($this->generateComposeDomains((string) $service) as $domain) {
|
||||
$alreadyExists = collect($this->domainRows)->contains(
|
||||
fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === DomainPortOverrides::withoutPort($domain)
|
||||
&& $row['service'] === $service
|
||||
);
|
||||
if (! $alreadyExists) {
|
||||
$this->domainRows[] = $this->makeRow($domain, $service);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->preview->generate_preview_fqdn(generateWithoutApplicationDomain: true);
|
||||
}
|
||||
$this->refreshDomains();
|
||||
$this->dispatch('success', 'Domain generated.');
|
||||
}
|
||||
|
||||
public function startEdit(int $index): void
|
||||
{
|
||||
if (! isset($this->domainRows[$index])) {
|
||||
return;
|
||||
}
|
||||
$this->editingIndex = $index;
|
||||
$this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']);
|
||||
$canonical = DomainPortOverrides::withoutPort($this->domainRows[$index]['url']);
|
||||
$savedPort = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($savedPort)) {
|
||||
$this->editingDomainParts['port'] = (string) $savedPort;
|
||||
}
|
||||
$this->dispatch('open-preview-domain-edit');
|
||||
}
|
||||
|
||||
public function updateDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
|
||||
return;
|
||||
}
|
||||
$domain = $this->validatedDomain($this->editingDomainParts, 'editingDomainParts.host');
|
||||
if ($domain === null) {
|
||||
return;
|
||||
}
|
||||
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
|
||||
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) {
|
||||
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
|
||||
|
||||
return;
|
||||
}
|
||||
if (blank(DomainUrlParts::split($domain)['port'] ?? null)) {
|
||||
$portOverrides = $this->preview->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($domain)]);
|
||||
$this->preview->domain_port_overrides = $portOverrides ?: null;
|
||||
}
|
||||
$this->domainRows[$this->editingIndex]['url'] = $domain;
|
||||
$this->domainRows[$this->editingIndex]['dns_status'] = 'pending';
|
||||
$this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.';
|
||||
$index = $this->editingIndex;
|
||||
$this->editingIndex = null;
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->dispatch('close-preview-domain-edit');
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
$this->checkDomainDns($index);
|
||||
}
|
||||
|
||||
public function confirmUseUnknownPort(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->forceUseUnknownPort = true;
|
||||
$this->showPortWarningModal = false;
|
||||
$action = $this->pendingPortAction;
|
||||
$this->pendingPortAction = null;
|
||||
|
||||
if ($action === 'update') {
|
||||
$this->updateDomain();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addDomain();
|
||||
}
|
||||
|
||||
public function cancelUseUnknownPort(): void
|
||||
{
|
||||
$this->showPortWarningModal = false;
|
||||
$this->forceUseUnknownPort = false;
|
||||
$this->unrecognizedPort = null;
|
||||
$this->pendingPortAction = null;
|
||||
}
|
||||
|
||||
public function removeDomain(int $index): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
if (! isset($this->domainRows[$index])) {
|
||||
return;
|
||||
}
|
||||
unset($this->domainRows[$index]);
|
||||
$this->domainRows = array_values($this->domainRows);
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$this->dispatch('success', 'Domain removed.');
|
||||
}
|
||||
|
||||
public function removeDomainByKey(string $domainKey): void
|
||||
{
|
||||
$index = collect($this->domainRows)->search(
|
||||
fn (array $row): bool => hash_equals($domainKey, $this->statusKey($row['url'], $row['service']))
|
||||
);
|
||||
|
||||
if ($index === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removeDomain((int) $index);
|
||||
}
|
||||
|
||||
public function checkAllDns(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
foreach (array_keys($this->domainRows) as $index) {
|
||||
$this->applyDnsCheck($index);
|
||||
}
|
||||
$this->persistDnsStatuses();
|
||||
}
|
||||
|
||||
public function checkDomainDns(int $index): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->applyDnsCheck($index);
|
||||
$this->persistDnsStatuses();
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
{
|
||||
$checkingRows = collect($this->domainRows)
|
||||
->where('dns_status', 'checking')
|
||||
->values();
|
||||
|
||||
$this->refreshDomains();
|
||||
|
||||
foreach ($checkingRows as $checkingRow) {
|
||||
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
|
||||
&& ($row['service'] ?? null) === ($checkingRow['service'] ?? null));
|
||||
|
||||
if (! is_array($row) || $row['dns_status'] === 'checking') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchDnsCheckNotification(string $url, string $status): void
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST) ?: $url;
|
||||
|
||||
match ($status) {
|
||||
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
|
||||
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
|
||||
default => $this->dispatch('info', "DNS check skipped for {$host}."),
|
||||
};
|
||||
}
|
||||
|
||||
private function applyDnsCheck(int $index): void
|
||||
{
|
||||
if (! isset($this->domainRows[$index])) {
|
||||
return;
|
||||
}
|
||||
$result = $this->checkUrlDns($this->domainRows[$index]['url'], (string) $index);
|
||||
$this->domainRows[$index]['dns_status'] = $result['status'];
|
||||
$this->domainRows[$index]['dns_message'] = $result['message'];
|
||||
}
|
||||
|
||||
private function checkUrlDns(string $url, string $key = 'domain'): array
|
||||
{
|
||||
$server = $this->preview->application->destination?->server;
|
||||
|
||||
return CheckDomainDns::run(
|
||||
[$key => $url],
|
||||
$server,
|
||||
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
|
||||
$this->preview->application->additional_servers->count() > 0,
|
||||
)[$key];
|
||||
}
|
||||
|
||||
private function refreshDomains(): void
|
||||
{
|
||||
$this->preview->refresh();
|
||||
$statuses = $this->preview->domain_dns_statuses ?? [];
|
||||
$rows = [];
|
||||
if ($this->preview->application->build_pack === 'dockercompose') {
|
||||
foreach (json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [] as $service => $entry) {
|
||||
foreach ($this->splitDomains(composeDomainEntryString($entry)) as $url) {
|
||||
$rows[] = $this->makeRow($url, (string) $service, $statuses);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($this->splitDomains($this->preview->fqdn) as $url) {
|
||||
$rows[] = $this->makeRow($url, null, $statuses);
|
||||
}
|
||||
}
|
||||
$this->domainRows = $rows;
|
||||
}
|
||||
|
||||
private function persistDomains(): bool
|
||||
{
|
||||
if ($this->preview->application->build_pack === 'dockercompose') {
|
||||
try {
|
||||
$composeServices = $this->composeServices(failOnError: true);
|
||||
} catch (\Throwable) {
|
||||
$this->refreshDomains();
|
||||
$this->dispatch('error', 'Compose configuration could not be parsed. Preview domains were not changed.');
|
||||
|
||||
return false;
|
||||
}
|
||||
$domains = collect($composeServices)
|
||||
->mapWithKeys(fn (string $service): array => [$service => ['domain' => '']])
|
||||
->all();
|
||||
$validRows = collect($this->domainRows)
|
||||
->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true));
|
||||
foreach ($validRows->groupBy('service') as $service => $rows) {
|
||||
$domains[$service] = ['domain' => $rows->pluck('url')->implode(',')];
|
||||
}
|
||||
$this->preview->docker_compose_domains = json_encode($domains);
|
||||
$this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null;
|
||||
} else {
|
||||
$this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null;
|
||||
}
|
||||
$normalized = DomainPortOverrides::normalize($this->preview->fqdn, $this->preview->domain_port_overrides);
|
||||
$this->preview->fqdn = $normalized['fqdn'];
|
||||
$this->preview->domain_port_overrides = $normalized['overrides'];
|
||||
if ($this->preview->application->build_pack === 'dockercompose' && is_array($domains ?? null)) {
|
||||
foreach ($domains as $service => $entry) {
|
||||
$serviceDomains = $this->splitDomains(composeDomainEntryString($entry));
|
||||
$domains[$service]['domain'] = collect($serviceDomains)
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
|
||||
->implode(',');
|
||||
}
|
||||
$this->preview->docker_compose_domains = json_encode($domains);
|
||||
}
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
$this->domainRows[$index]['url'] = DomainPortOverrides::withoutPort($row['url']);
|
||||
}
|
||||
$this->preview->save();
|
||||
$this->persistDnsStatuses();
|
||||
$this->refreshDomains();
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('previewDomainsChanged');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function persistDnsStatuses(): void
|
||||
{
|
||||
$statuses = [];
|
||||
foreach ($this->domainRows as $row) {
|
||||
$statuses[$this->statusKey($row['url'], $row['service'])] = [
|
||||
'status' => $row['dns_status'],
|
||||
'message' => $row['dns_message'],
|
||||
'check_id' => $row['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$statuses): void {
|
||||
$preview = ApplicationPreview::query()->lockForUpdate()->findOrFail($this->preview->id);
|
||||
$storedStatuses = $preview->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($statuses as $key => $status) {
|
||||
$storedStatus = $storedStatuses[$key] ?? null;
|
||||
if (! is_array($storedStatus)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$localCheckId = $status['check_id'] ?? null;
|
||||
$storedCheckId = $storedStatus['check_id'] ?? null;
|
||||
|
||||
if (($storedCheckId !== null && $localCheckId !== $storedCheckId)
|
||||
|| ($status['status'] === 'checking' && ($storedStatus['status'] ?? null) !== 'checking')) {
|
||||
$statuses[$key] = $storedStatus;
|
||||
}
|
||||
}
|
||||
|
||||
$preview->domain_dns_statuses = $statuses ?: null;
|
||||
$preview->save();
|
||||
});
|
||||
|
||||
$this->preview->domain_dns_statuses = $statuses ?: null;
|
||||
}
|
||||
|
||||
private function validatedDomain(array $parts, string $errorKey): ?string
|
||||
{
|
||||
$domain = DomainUrlParts::compose(...$parts);
|
||||
$validator = validator(['domain' => $domain], ['domain' => ValidationPatterns::applicationDomainRules()]);
|
||||
if ($validator->fails()) {
|
||||
$this->addError($errorKey, $validator->errors()->first('domain'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return ValidationPatterns::normalizeApplicationDomains($domain);
|
||||
}
|
||||
|
||||
private function makeRow(string $url, ?string $service, array $statuses = []): array
|
||||
{
|
||||
$status = $statuses[$this->statusKey($url, $service)] ?? [];
|
||||
$port = $this->effectiveDomainInternalPort($url, $service);
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'service' => $service,
|
||||
'internal_port' => $port['internal_port'],
|
||||
'has_port_override' => $port['has_port_override'],
|
||||
'dns_status' => $status['status'] ?? 'pending',
|
||||
'dns_message' => $status['message'] ?? 'DNS has not been checked yet.',
|
||||
'check_id' => $status['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{scheme: string, host: string, port: string, path: string} $parts
|
||||
*/
|
||||
private function portFromParts(array $parts): ?int
|
||||
{
|
||||
$port = trim((string) ($parts['port'] ?? ''));
|
||||
if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $port;
|
||||
}
|
||||
|
||||
private function currentRowPort(string $url): ?int
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$override = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($override) && (int) $override > 0) {
|
||||
return (int) $override;
|
||||
}
|
||||
|
||||
$legacy = DomainUrlParts::split($url)['port'] ?? '';
|
||||
|
||||
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
|
||||
}
|
||||
|
||||
private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool
|
||||
{
|
||||
if ($this->forceUseUnknownPort || $port === null) {
|
||||
return false;
|
||||
}
|
||||
if ($currentPort !== null && $port === $currentPort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->preview->application->portRequiresConfirmation($port);
|
||||
}
|
||||
|
||||
private function openPortWarning(?int $port, string $action): void
|
||||
{
|
||||
$this->unrecognizedPort = $port;
|
||||
$this->pendingPortAction = $action;
|
||||
$this->showPortWarningModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{internal_port: ?int, has_port_override: bool}
|
||||
*/
|
||||
private function effectiveDomainInternalPort(string $url, ?string $service = null): array
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$overrides = $this->preview->domain_port_overrides ?? [];
|
||||
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
|
||||
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
|
||||
$hasMapEntry = array_key_exists($canonical, $overrides);
|
||||
|
||||
if ($hasMapEntry) {
|
||||
return [
|
||||
'internal_port' => (int) $overrides[$canonical],
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($legacyPort !== null) {
|
||||
return [
|
||||
'internal_port' => $legacyPort,
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->preview->application->settings?->is_static) {
|
||||
return [
|
||||
'internal_port' => 80,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service);
|
||||
if ($composePort !== null) {
|
||||
return [
|
||||
'internal_port' => $composePort,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$exposed = $this->preview->application->ports_exposes_array;
|
||||
$defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0
|
||||
? (int) $exposed[0]
|
||||
: null;
|
||||
|
||||
return [
|
||||
'internal_port' => $defaultPort,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function statusKey(string $url, ?string $service): string
|
||||
{
|
||||
return hash('sha256', $url.'|'.($service ?? ''));
|
||||
}
|
||||
|
||||
private function splitDomains(?string $domains): array
|
||||
{
|
||||
return str($domains)->explode(',')->map(fn ($domain) => trim((string) $domain))->filter()->values()->all();
|
||||
}
|
||||
|
||||
private function generateComposeDomains(string $service): array
|
||||
{
|
||||
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
|
||||
$domainString = getComposeServiceDomainString($applicationDomains, $service);
|
||||
|
||||
if (empty($domainString)) {
|
||||
$domainString = generateUrl(
|
||||
server: $this->preview->application->destination->server,
|
||||
random: str($service)->slug().'-'.$this->preview->application->uuid,
|
||||
);
|
||||
}
|
||||
|
||||
return collect($this->splitDomains($domainString))->map(function (string $domain): string {
|
||||
$generated = $this->preview->generatedPreviewDomain($domain);
|
||||
if (filled($generated['port'])) {
|
||||
$overrides = $this->preview->domain_port_overrides ?? [];
|
||||
$overrides[$generated['url']] = $generated['port'];
|
||||
$this->preview->domain_port_overrides = $overrides;
|
||||
}
|
||||
|
||||
return $generated['url'];
|
||||
})->all();
|
||||
}
|
||||
|
||||
private function composeServices(bool $failOnError = false): array
|
||||
{
|
||||
try {
|
||||
$parsedCompose = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
|
||||
$services = data_get($parsedCompose, 'services', []);
|
||||
if (! is_iterable($services)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$previewSuffix = '-pr-'.$this->preview->pull_request_id;
|
||||
$serviceNames = [];
|
||||
foreach ($services as $serviceName => $service) {
|
||||
if (isDatabaseImage(data_get($service, 'image'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$serviceName = (string) $serviceName;
|
||||
if (str_ends_with($serviceName, $previewSuffix)) {
|
||||
$serviceName = substr($serviceName, 0, -strlen($previewSuffix));
|
||||
}
|
||||
$serviceNames[] = $serviceName;
|
||||
}
|
||||
|
||||
return array_values(array_unique($serviceNames));
|
||||
} catch (\Throwable $exception) {
|
||||
if ($failOnError) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
|
@ -16,6 +15,8 @@ class Previews extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected $listeners = ['previewDomainsChanged' => 'refreshPreviewDomains'];
|
||||
|
||||
public Application $application;
|
||||
|
||||
public string $deployment_uuid;
|
||||
|
|
@ -26,16 +27,6 @@ class Previews extends Component
|
|||
|
||||
public int $rate_limit_remaining;
|
||||
|
||||
public $domainConflicts = [];
|
||||
|
||||
public $showDomainConflictModal = false;
|
||||
|
||||
public $forceSaveDomains = false;
|
||||
|
||||
public $pendingPreviewId = null;
|
||||
|
||||
public array $previewFqdns = [];
|
||||
|
||||
public array $previewDockerTags = [];
|
||||
|
||||
public ?int $manualPullRequestId = null;
|
||||
|
|
@ -43,7 +34,6 @@ class Previews extends Component
|
|||
public ?string $manualDockerTag = null;
|
||||
|
||||
protected $rules = [
|
||||
'previewFqdns.*' => 'string|nullable',
|
||||
'previewDockerTags.*' => 'string|nullable',
|
||||
'manualPullRequestId' => 'integer|min:1|nullable',
|
||||
'manualDockerTag' => 'string|nullable',
|
||||
|
|
@ -53,31 +43,23 @@ public function mount()
|
|||
{
|
||||
$this->pull_requests = collect();
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->syncData(false);
|
||||
$this->syncDockerTags();
|
||||
}
|
||||
|
||||
private function syncData(bool $toModel = false): void
|
||||
private function syncDockerTags(): void
|
||||
{
|
||||
if ($toModel) {
|
||||
foreach ($this->previewFqdns as $key => $fqdn) {
|
||||
$preview = $this->application->previews->get($key);
|
||||
if ($preview) {
|
||||
$preview->fqdn = $fqdn;
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->previewFqdns = [];
|
||||
$this->previewDockerTags = [];
|
||||
foreach ($this->application->previews as $key => $preview) {
|
||||
$this->previewFqdns[$key] = $preview->fqdn;
|
||||
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
|
||||
}
|
||||
$this->previewDockerTags = [];
|
||||
foreach ($this->application->previews as $key => $preview) {
|
||||
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshPreviewDomains(): void
|
||||
{
|
||||
$this->application->refresh();
|
||||
$this->syncDockerTags();
|
||||
}
|
||||
|
||||
public function load_prs()
|
||||
{
|
||||
try {
|
||||
|
|
@ -92,103 +74,28 @@ public function load_prs()
|
|||
}
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
{
|
||||
$this->forceSaveDomains = true;
|
||||
$this->showDomainConflictModal = false;
|
||||
if ($this->pendingPreviewId) {
|
||||
$this->save_preview($this->pendingPreviewId);
|
||||
$this->pendingPreviewId = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function save_preview($preview_id)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$success = true;
|
||||
$preview = $this->application->previews->find($preview_id);
|
||||
|
||||
if (! $preview) {
|
||||
throw new \Exception('Preview not found');
|
||||
}
|
||||
|
||||
// Find the key for this preview in the collection
|
||||
$previewKey = $this->application->previews->search(function ($item) use ($preview_id) {
|
||||
return $item->id == $preview_id;
|
||||
});
|
||||
|
||||
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
|
||||
$this->validate([
|
||||
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
|
||||
]);
|
||||
|
||||
$fqdn = $this->previewFqdns[$previewKey];
|
||||
|
||||
if (! empty($fqdn)) {
|
||||
$fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
|
||||
$this->previewFqdns[$previewKey] = $fqdn;
|
||||
|
||||
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
|
||||
$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;
|
||||
}
|
||||
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application, domain: $fqdn);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
$this->pendingPreviewId = $preview_id;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
}
|
||||
if ($previewKey === false) {
|
||||
throw new \Exception('Preview not found');
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
$this->syncData(true);
|
||||
$preview->save();
|
||||
$this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function generate_preview($preview_id)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$preview = $this->application->previews->find($preview_id);
|
||||
if (! $preview) {
|
||||
$this->dispatch('error', 'Preview not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->application->build_pack === 'dockercompose') {
|
||||
$preview->generate_preview_fqdn_compose();
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
$this->dispatch('success', 'Domain generated.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$preview->generate_preview_fqdn();
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('success', 'Domain generated.');
|
||||
$this->validateOnly("previewDockerTags.{$previewKey}");
|
||||
$preview->docker_registry_image_tag = $this->previewDockerTags[$previewKey] ?? null;
|
||||
$preview->save();
|
||||
$this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -211,7 +118,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null,
|
|||
}
|
||||
$found->generate_preview_fqdn_compose();
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
$this->syncDockerTags();
|
||||
} else {
|
||||
$this->setDeploymentUuid();
|
||||
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
|
|
@ -227,9 +134,9 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null,
|
|||
$found->docker_registry_image_tag = $docker_registry_image_tag;
|
||||
$found->save();
|
||||
}
|
||||
$found->generate_preview_fqdn();
|
||||
$found->generate_preview_fqdn(generateWithoutApplicationDomain: true);
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
$this->syncDockerTags();
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('success', 'Preview added.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class PreviewsCompose extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $service;
|
||||
|
||||
public $serviceName;
|
||||
|
||||
public ApplicationPreview $preview;
|
||||
|
||||
public ?string $domain = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->domain = data_get($this->service, 'domain');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.previews-compose');
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->validate([
|
||||
'domain' => ValidationPatterns::applicationDomainRules(),
|
||||
]);
|
||||
|
||||
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
|
||||
$this->persistPreviewDomain($this->domain);
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('success', 'Domain saved.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function generate()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->preview->application);
|
||||
|
||||
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
|
||||
$domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName);
|
||||
|
||||
// If no domain is set in the main application, generate a default domain
|
||||
if (empty($domain_string)) {
|
||||
$server = $this->preview->application->destination->server;
|
||||
$template = $this->preview->application->preview_url_template;
|
||||
$random = new_public_id();
|
||||
|
||||
// Generate a unique domain like main app services do
|
||||
$generated_fqdn = generateUrl(server: $server, random: $random);
|
||||
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
||||
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
|
||||
} else {
|
||||
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
|
||||
throw new \InvalidArgumentException($error);
|
||||
}
|
||||
|
||||
// Use the existing domain from the main application
|
||||
// Handle multiple domains separated by commas
|
||||
$domain_list = ValidationPatterns::applicationDomainList($domain_string);
|
||||
$preview_fqdns = [];
|
||||
$template = $this->preview->application->preview_url_template;
|
||||
$random = new_public_id();
|
||||
|
||||
foreach ($domain_list as $single_domain) {
|
||||
$single_domain = trim($single_domain);
|
||||
if (empty($single_domain)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = Url::fromString($single_domain);
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$portInt = $url->getPort();
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
||||
$preview_fqdns[] = "$schema://$preview_fqdn{$port}";
|
||||
}
|
||||
|
||||
$preview_fqdn = implode(',', $preview_fqdns);
|
||||
}
|
||||
|
||||
$this->domain = $preview_fqdn;
|
||||
$this->persistPreviewDomain($this->domain);
|
||||
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('success', 'Domain generated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function persistPreviewDomain(?string $domain): void
|
||||
{
|
||||
$docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: [];
|
||||
$serviceNames = $this->previewServiceNames($docker_compose_domains);
|
||||
$storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames)
|
||||
?? (string) $this->serviceName;
|
||||
|
||||
$docker_compose_domains = putComposeServiceDomain(
|
||||
$docker_compose_domains,
|
||||
$storageKey,
|
||||
$domain,
|
||||
$serviceNames,
|
||||
);
|
||||
$docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames);
|
||||
|
||||
$this->serviceName = $storageKey;
|
||||
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
|
||||
$this->preview->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $previewDomains
|
||||
* @return list<string>
|
||||
*/
|
||||
private function previewServiceNames(array $previewDomains): array
|
||||
{
|
||||
$parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
|
||||
$fromCompose = collect(data_get($parsedServices, 'services', []))
|
||||
->keys()
|
||||
->map(function ($serviceName) {
|
||||
return str((string) $serviceName)
|
||||
->replaceLast('-pr-'.$this->preview->pull_request_id, '')
|
||||
->toString();
|
||||
})
|
||||
->all();
|
||||
|
||||
$domainKeys = collect(array_keys($previewDomains))
|
||||
->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []))
|
||||
->map(fn ($name) => (string) $name);
|
||||
$unmapped = $domainKeys
|
||||
->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null)
|
||||
->all();
|
||||
|
||||
return collect($fromCompose)
|
||||
->merge(preferredComposeServiceNamesFromDomainKeys(
|
||||
$fromCompose === [] ? $domainKeys->all() : $unmapped
|
||||
))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ public function updatedGitCommitSha()
|
|||
$this->gitCommitSha = trim($this->gitCommitSha);
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ public function refreshStatus(): void
|
|||
$this->status = $database->status;
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->backup->enabled = $this->backupEnabled;
|
||||
|
|
@ -212,14 +212,14 @@ public function delete($password, $selectedActions = [])
|
|||
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
|
||||
$serviceDatabase = $this->backup->database;
|
||||
|
||||
return redirect()->route('project.service.database.backups', [
|
||||
return redirectRoute($this, 'project.service.database.backups', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'environment_uuid' => $this->parameters['environment_uuid'],
|
||||
'service_uuid' => $serviceDatabase->service->uuid,
|
||||
'stack_service_uuid' => $serviceDatabase->uuid,
|
||||
]);
|
||||
} else {
|
||||
return redirect()->route('project.database.backup.index', [
|
||||
return redirectRoute($this, 'project.database.backup.index', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'environment_uuid' => $this->parameters['environment_uuid'],
|
||||
'database_uuid' => $this->parameters['database_uuid'],
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class BackupExecutions extends Component
|
||||
|
|
@ -37,12 +36,12 @@ class BackupExecutions extends Component
|
|||
|
||||
public $delete_backup_sftp = false;
|
||||
|
||||
public function getListeners()
|
||||
public function getListeners(): array
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$teamId = currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions',
|
||||
"echo-private:team.{$teamId},BackupCreated" => 'refreshBackupExecutions',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ protected function messages(): array
|
|||
);
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -199,6 +199,7 @@ public function instantSave()
|
|||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ protected function messages(): array
|
|||
);
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -191,6 +191,7 @@ public function instantSave()
|
|||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ public function getListeners()
|
|||
|
||||
public function activityFinished()
|
||||
{
|
||||
if (auth()->user()->cannot('update', $this->database)) {
|
||||
$this->dispatch('refresh');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Only set started_at if database is actually running
|
||||
if ($this->database->isRunning()) {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ public function mount(): void
|
|||
$this->syncData();
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ class InitScript extends Component
|
|||
#[Locked]
|
||||
public int $index;
|
||||
|
||||
#[Locked]
|
||||
public string $originalFilename;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $filename = null;
|
||||
|
||||
|
|
@ -33,6 +36,7 @@ public function mount()
|
|||
try {
|
||||
$this->index = data_get($this->script, 'index');
|
||||
$this->filename = data_get($this->script, 'filename');
|
||||
$this->originalFilename = (string) data_get($this->script, 'filename');
|
||||
$this->content = data_get($this->script, 'content');
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -47,7 +51,7 @@ public function submit()
|
|||
$this->script['index'] = $this->index;
|
||||
$this->script['content'] = $this->content;
|
||||
$this->script['filename'] = $this->filename;
|
||||
$this->dispatch('save_init_script', $this->script);
|
||||
$this->dispatch('save_init_script', $this->script, $this->originalFilename);
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ protected function messages(): array
|
|||
);
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -196,6 +196,7 @@ public function instantSave()
|
|||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -244,6 +244,7 @@ public function instantSave()
|
|||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -237,6 +237,7 @@ public function instantSave()
|
|||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -244,6 +244,7 @@ public function instantSave()
|
|||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -240,6 +240,7 @@ public function instantSave(?bool $isPublic = null)
|
|||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
|
||||
|
|
@ -247,16 +248,16 @@ public function instantSave(?bool $isPublic = null)
|
|||
}
|
||||
}
|
||||
|
||||
public function save_init_script($script)
|
||||
public function save_init_script($script, string $originalFilename)
|
||||
{
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
$initScripts = collect($this->initScripts ?? []);
|
||||
|
||||
$existingScript = $initScripts->firstWhere('filename', $script['filename']);
|
||||
$oldScript = $initScripts->firstWhere('index', $script['index']);
|
||||
$oldScript = $initScripts->firstWhere('filename', $originalFilename);
|
||||
|
||||
if ($existingScript && $existingScript['index'] !== $script['index']) {
|
||||
if ($existingScript && $script['filename'] !== $originalFilename) {
|
||||
$this->dispatch('error', 'A script with this filename already exists.');
|
||||
|
||||
return;
|
||||
|
|
@ -285,11 +286,10 @@ public function save_init_script($script)
|
|||
}
|
||||
}
|
||||
|
||||
$index = $initScripts->search(function ($item) use ($script) {
|
||||
return $item['index'] === $script['index'];
|
||||
});
|
||||
$index = $initScripts->search(fn ($item) => $item['filename'] === $originalFilename);
|
||||
|
||||
if ($index !== false) {
|
||||
$script['index'] = $oldScript['index'];
|
||||
$initScripts[$index] = $script;
|
||||
} else {
|
||||
$initScripts->push($script);
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -235,6 +235,7 @@ public function instantSave()
|
|||
}
|
||||
$this->dispatch('databaseUpdated');
|
||||
} catch (\Throwable $e) {
|
||||
$this->authorize('update', $this->database);
|
||||
$this->isPublic = ! $this->isPublic;
|
||||
$this->syncData(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ public function mount(string $project_uuid)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ public function mount(string $project_uuid, string $environment_uuid)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -53,10 +53,7 @@ public function render(): View
|
|||
'uuid' => $project->uuid,
|
||||
'name' => $project->name,
|
||||
'description' => $project->description,
|
||||
'iconUrl' => $project->icon_path ? route('project.icon', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'v' => $project->updated_at->timestamp,
|
||||
]) : null,
|
||||
'iconUrl' => $project->icon_path ? project_icon_url($project) : null,
|
||||
'href' => $project->navigateTo(),
|
||||
'environmentCount' => $project->environments->count(),
|
||||
'resourceCount' => $resourceCount,
|
||||
|
|
|
|||
|
|
@ -111,38 +111,14 @@ public function loadServices()
|
|||
$templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services);
|
||||
|
||||
$services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) {
|
||||
$default_logo = 'svgs/default.webp';
|
||||
$logo = data_get($service, 'logo');
|
||||
|
||||
if (is_string($logo) && str_starts_with($logo, 'svg/')) {
|
||||
$normalizedLogo = 'svgs/'.str($logo)->after('svg/');
|
||||
if (file_exists(public_path($normalizedLogo))) {
|
||||
$logo = $normalizedLogo;
|
||||
}
|
||||
}
|
||||
|
||||
$hasLogo = is_string($logo)
|
||||
&& basename($logo) !== basename($default_logo)
|
||||
&& file_exists(public_path($logo));
|
||||
|
||||
if (! $hasLogo) {
|
||||
$logo = $default_logo;
|
||||
}
|
||||
|
||||
$local_logo_path = public_path($logo);
|
||||
$serviceKey = (string) $key;
|
||||
|
||||
return [
|
||||
'id' => $serviceKey,
|
||||
'name' => str($serviceKey)->headline(),
|
||||
'docsSlug' => str($serviceKey)->lower()->value(),
|
||||
'has_logo' => $hasLogo,
|
||||
'logo' => asset($logo),
|
||||
'logo_github_url' => file_exists($local_logo_path)
|
||||
? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo
|
||||
: asset($default_logo),
|
||||
'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null,
|
||||
] + (array) $service;
|
||||
] + service_logo_urls(data_get($service, 'logo')) + (array) $service;
|
||||
})->all();
|
||||
|
||||
// Extract unique categories from services
|
||||
|
|
|
|||
|
|
@ -187,6 +187,11 @@ private function toSearchableArray(Collection $items, string $type, string $type
|
|||
'fqdn' => $item->fqdn ?? null,
|
||||
'description' => $item->description ?? null,
|
||||
'status' => $item->status ?? '',
|
||||
'restartLimitReached' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit(),
|
||||
'restartCount' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit()
|
||||
? max($item->restart_count ?? 0, $item->max_restart_count ?? 0)
|
||||
: ($item->restart_count ?? 0),
|
||||
'maxRestartCount' => $item->max_restart_count ?? 0,
|
||||
'server_status' => $item->server_status ?? null,
|
||||
'hrefLink' => $item->hrefLink ?? '',
|
||||
'destination' => [
|
||||
|
|
|
|||
124
app/Livewire/Project/Service/BackupExecutions.php
Normal file
124
app/Livewire/Project/Service/BackupExecutions.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
||||
class BackupExecutions extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Service $service;
|
||||
|
||||
public bool $executionModalOpen = false;
|
||||
|
||||
public ?array $selectedExecution = null;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = currentTeam()->id;
|
||||
|
||||
return [
|
||||
'modalClosed' => 'closeExecutionModal',
|
||||
"echo-private:team.{$teamId},BackupCreated" => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(Service $service): void
|
||||
{
|
||||
abort_unless($service->environment?->project?->team_id === currentTeam()->id, 404);
|
||||
$this->service = $service;
|
||||
$this->authorize('view', $this->service);
|
||||
}
|
||||
|
||||
public function openExecution(string $executionUuid): void
|
||||
{
|
||||
$this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid);
|
||||
abort_unless($this->selectedExecution, 404);
|
||||
$this->executionModalOpen = true;
|
||||
}
|
||||
|
||||
public function closeExecutionModal(): void
|
||||
{
|
||||
$this->executionModalOpen = false;
|
||||
$this->selectedExecution = null;
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.service.backup-executions', [
|
||||
'executions' => $this->executions(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function executions(): Collection
|
||||
{
|
||||
$databaseScheduleIds = ScheduledDatabaseBackup::query()
|
||||
->where('database_type', (new ServiceDatabase)->getMorphClass())
|
||||
->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id))
|
||||
->pluck('id');
|
||||
|
||||
$databaseExecutions = ScheduledDatabaseBackupExecution::query()
|
||||
->with('scheduledDatabaseBackup.database')
|
||||
->whereIn('scheduled_database_backup_id', $databaseScheduleIds)
|
||||
->latest()
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(fn (ScheduledDatabaseBackupExecution $execution): array => [
|
||||
'id' => 'database:'.$execution->id,
|
||||
'uuid' => $execution->uuid,
|
||||
'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name,
|
||||
'type' => 'Database',
|
||||
'schedule' => $execution->scheduledDatabaseBackup->frequency,
|
||||
'status' => $execution->status,
|
||||
'started_at' => $execution->created_at,
|
||||
'size' => $execution->size,
|
||||
'message' => $execution->message,
|
||||
'filename' => $execution->filename,
|
||||
'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted
|
||||
? route('download.backup', $execution->id)
|
||||
: null,
|
||||
]);
|
||||
|
||||
$volumeSchedules = ScheduledVolumeBackup::query()
|
||||
->with('backupable.resource')
|
||||
->forService($this->service)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
$volumeExecutions = ScheduledVolumeBackupExecution::query()
|
||||
->whereIn('scheduled_volume_backup_id', $volumeSchedules->keys())
|
||||
->latest()
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(function (ScheduledVolumeBackupExecution $execution) use ($volumeSchedules): array {
|
||||
$schedule = $volumeSchedules->get($execution->scheduled_volume_backup_id);
|
||||
|
||||
return [
|
||||
'id' => 'storage:'.$execution->id,
|
||||
'uuid' => $execution->uuid,
|
||||
'target' => $schedule->targetName(),
|
||||
'type' => $schedule->targetType(),
|
||||
'schedule' => $schedule->frequency,
|
||||
'status' => $execution->status,
|
||||
'started_at' => $execution->created_at,
|
||||
'size' => $execution->size,
|
||||
'message' => $execution->message,
|
||||
'filename' => $execution->filename,
|
||||
'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted
|
||||
? route('download.volume-backup', $execution->id)
|
||||
: null,
|
||||
];
|
||||
});
|
||||
|
||||
return $databaseExecutions->concat($volumeExecutions)->sortByDesc('started_at')->values();
|
||||
}
|
||||
}
|
||||
|
|
@ -22,8 +22,6 @@ class DatabaseBackups extends Component
|
|||
|
||||
public array $query;
|
||||
|
||||
public bool $isImportSupported = false;
|
||||
|
||||
public ?ScheduledDatabaseBackup $backup = null;
|
||||
|
||||
public string $section = 'index';
|
||||
|
|
@ -32,7 +30,7 @@ class DatabaseBackups extends Component
|
|||
|
||||
protected $listeners = ['refreshScheduledBackups' => '$refresh'];
|
||||
|
||||
public function mount()
|
||||
public function mount(): mixed
|
||||
{
|
||||
try {
|
||||
$this->parameters = array_filter(
|
||||
|
|
@ -67,10 +65,13 @@ public function mount()
|
|||
return redirect()->route('project.service.index', $this->parameters);
|
||||
}
|
||||
|
||||
// Check if import is supported for this database type
|
||||
$dbType = $this->serviceDatabase->databaseType();
|
||||
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
|
||||
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
|
||||
if (! request()->route('backup_uuid')) {
|
||||
return redirect()->route('project.service.volume-backups.index', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'environment_uuid' => $this->parameters['environment_uuid'],
|
||||
'service_uuid' => $this->parameters['service_uuid'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (request()->route('backup_uuid')) {
|
||||
$this->backup = $this->serviceDatabase->scheduledBackups()
|
||||
|
|
@ -85,6 +86,14 @@ public function mount()
|
|||
'project.service.database.backup.danger' => 'danger',
|
||||
default => 'general',
|
||||
};
|
||||
|
||||
$routeParameters = [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'environment_uuid' => $this->parameters['environment_uuid'],
|
||||
'service_uuid' => $this->parameters['service_uuid'],
|
||||
];
|
||||
|
||||
return redirect()->route('project.service.volume-backups.index', $routeParameters);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
|
@ -306,30 +307,15 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr
|
|||
{
|
||||
$entry = $stored[$url] ?? null;
|
||||
$displayName = $app->human_name ?: $app->name;
|
||||
$port = $this->effectiveDomainInternalPort($url, $app);
|
||||
|
||||
if (is_array($entry) && filled(data_get($entry, 'status'))) {
|
||||
return [
|
||||
'service_application_id' => $app->id,
|
||||
'service_name' => $displayName,
|
||||
'service_image' => $app->image,
|
||||
'url' => $url,
|
||||
'dns_status' => (string) data_get($entry, 'status', 'pending'),
|
||||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
'needs_force_add' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
$row = [
|
||||
'service_application_id' => $app->id,
|
||||
'service_name' => $displayName,
|
||||
'service_image' => $app->image,
|
||||
'url' => $url,
|
||||
'internal_port' => $port['internal_port'],
|
||||
'has_port_override' => $port['has_port_override'],
|
||||
'dns_status' => 'pending',
|
||||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
|
|
@ -340,6 +326,48 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr
|
|||
'suggestion_label' => null,
|
||||
'needs_force_add' => false,
|
||||
];
|
||||
|
||||
if (is_array($entry) && filled(data_get($entry, 'status'))) {
|
||||
$row['dns_status'] = (string) data_get($entry, 'status', 'pending');
|
||||
$row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.');
|
||||
$row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp;
|
||||
$row['checked_at'] = data_get($entry, 'checked_at');
|
||||
$row['check_id'] = data_get($entry, 'check_id');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{internal_port: ?int, has_port_override: bool}
|
||||
*/
|
||||
protected function effectiveDomainInternalPort(string $url, ServiceApplication $app): array
|
||||
{
|
||||
$canonical = DomainPortOverrides::withoutPort($url);
|
||||
$overrides = $app->domain_port_overrides ?? [];
|
||||
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
|
||||
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
|
||||
|
||||
if (array_key_exists($canonical, $overrides)) {
|
||||
return [
|
||||
'internal_port' => (int) $overrides[$canonical],
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if ($legacyPort !== null && $legacyPort > 0) {
|
||||
return [
|
||||
'internal_port' => $legacyPort,
|
||||
'has_port_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$requiredPort = $app->getRequiredPort();
|
||||
|
||||
return [
|
||||
'internal_port' => ($requiredPort !== null && $requiredPort > 0) ? $requiredPort : null,
|
||||
'has_port_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -990,8 +1018,11 @@ public function addDomain(): void
|
|||
->all()
|
||||
: [];
|
||||
$current = collect($this->splitDomains($app->fqdn));
|
||||
$currentCanonicalDomains = $current->map(
|
||||
fn (string $url): string => DomainPortOverrides::withoutPort($url)
|
||||
);
|
||||
foreach ($newUrls as $url) {
|
||||
if ($current->contains($url)) {
|
||||
if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) {
|
||||
$this->addError('newDomain', "Domain {$url} is already configured for this service.");
|
||||
|
||||
return;
|
||||
|
|
@ -1111,6 +1142,12 @@ public function startEdit(int $index): void
|
|||
$this->editingIndex = $index;
|
||||
$this->editingDomain = $this->domainRows[$index]['url'];
|
||||
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
|
||||
$app = $this->findServiceApp((int) $this->domainRows[$index]['service_application_id']);
|
||||
$canonical = DomainPortOverrides::withoutPort($this->editingDomain);
|
||||
$savedPort = ($app?->domain_port_overrides ?? [])[$canonical] ?? null;
|
||||
if (filled($savedPort)) {
|
||||
$this->editingDomainParts['port'] = (string) $savedPort;
|
||||
}
|
||||
$this->editingDomainPartsChanged = false;
|
||||
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
|
||||
$this->editDomainDnsFailed = false;
|
||||
|
|
@ -1144,7 +1181,7 @@ public function updateDomain(): void
|
|||
return;
|
||||
}
|
||||
|
||||
if ($this->editingDomainPartsChanged) {
|
||||
if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) {
|
||||
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
|
||||
}
|
||||
$this->validateOnly('editingDomain');
|
||||
|
|
@ -1166,7 +1203,17 @@ public function updateDomain(): void
|
|||
$current = collect($this->splitDomains($app->fqdn));
|
||||
$wasNoindexed = $app->isDomainNoindexed($oldUrl);
|
||||
|
||||
if ($newUrl !== $oldUrl && $current->contains($newUrl)) {
|
||||
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
|
||||
$portOverrides = $app->domain_port_overrides ?? [];
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
|
||||
unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]);
|
||||
$app->domain_port_overrides = $portOverrides ?: null;
|
||||
}
|
||||
|
||||
$otherCanonicalDomains = $current
|
||||
->reject(fn (string $url): bool => $url === $oldUrl)
|
||||
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url));
|
||||
if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) {
|
||||
$this->addError('editingDomain', "Domain {$newUrl} is already configured for this service.");
|
||||
|
||||
return;
|
||||
|
|
@ -1243,6 +1290,28 @@ public function removeDomain(int $index): void
|
|||
}
|
||||
}
|
||||
|
||||
public function removeDomainByKey(string $domainKey): void
|
||||
{
|
||||
$index = collect($this->domainRows)->search(
|
||||
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
|
||||
&& hash_equals($domainKey, $this->domainRowKey($row))
|
||||
);
|
||||
|
||||
if ($index === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removeDomain((int) $index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{url: string, service_application_id: int|string} $row
|
||||
*/
|
||||
private function domainRowKey(array $row): string
|
||||
{
|
||||
return hash('sha256', $row['url'].'|'.$row['service_application_id']);
|
||||
}
|
||||
|
||||
public function addSuggestedDomain(int $index): void
|
||||
{
|
||||
try {
|
||||
|
|
@ -1376,8 +1445,9 @@ protected function saveDomainListForApp(
|
|||
if (! $this->forceRemovePort) {
|
||||
$requiredPort = $app->getRequiredPort();
|
||||
if ($requiredPort !== null && $domainString) {
|
||||
$previousFqdn = $app->getOriginal('fqdn');
|
||||
foreach ($this->splitDomains($domainString) as $fqdn) {
|
||||
if (ServiceApplication::extractPortFromUrl($fqdn) === null) {
|
||||
if ($app->portRequiresConfirmation($fqdn, $requiredPort, is_string($previousFqdn) ? $previousFqdn : null)) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
$app->refresh();
|
||||
|
|
@ -1425,6 +1495,7 @@ protected function checkUrlsDns(array $urls, ?int $serviceApplicationId = null):
|
|||
$urlSet = array_fill_keys($urls, true);
|
||||
$server = $this->service->server;
|
||||
$skipDns = ! $this->dnsValidationEnabled || ! $server;
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
$url = $row['url'] ?? null;
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ public function saveEditedCompose()
|
|||
try {
|
||||
$this->authorize('update', $this->service);
|
||||
$this->dispatch('saveCompose', $this->dockerComposeRaw);
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('storageCountsChanged')->to(Storage::class);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,18 +46,18 @@ public function mount()
|
|||
$this->syncData();
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Sync to model
|
||||
$this->application->fqdn = $this->fqdn;
|
||||
$this->application->setEditableUrls($this->fqdn);
|
||||
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Sync from model
|
||||
$this->fqdn = $this->application->fqdn;
|
||||
$this->fqdn = $this->application->url;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +84,10 @@ public function cancelRemovePort()
|
|||
public function submit()
|
||||
{
|
||||
try {
|
||||
$persistedApplication = $this->application->fresh();
|
||||
$previousEditableUrls = $persistedApplication->url;
|
||||
$previousFqdn = $persistedApplication->fqdn;
|
||||
$previousPortOverrides = $persistedApplication->domain_port_overrides;
|
||||
$this->authorize('update', $this->application);
|
||||
$this->validate();
|
||||
|
||||
|
|
@ -93,7 +97,7 @@ public function submit()
|
|||
$this->dispatch('warning', __('warning.sslipdomain'));
|
||||
}
|
||||
// Sync to model for domain conflict check (without validation)
|
||||
$this->application->fqdn = $this->fqdn;
|
||||
$this->application->setEditableUrls($this->fqdn);
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
|
|
@ -113,29 +117,21 @@ public function submit()
|
|||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
$fqdns = str($this->fqdn)->trim()->explode(',');
|
||||
$missingPort = false;
|
||||
|
||||
foreach ($fqdns as $fqdn) {
|
||||
$fqdn = trim($fqdn);
|
||||
if (empty($fqdn)) {
|
||||
foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) {
|
||||
$fqdn = trim((string) $fqdn);
|
||||
if ($fqdn === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$port = ServiceApplication::extractPortFromUrl($fqdn);
|
||||
if ($port === null) {
|
||||
$missingPort = true;
|
||||
break;
|
||||
if ($this->application->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
$this->application->fqdn = $previousFqdn;
|
||||
$this->application->domain_port_overrides = $previousPortOverrides;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingPort) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ public function refreshBackupStatus(): void
|
|||
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
if ($this->fileStorage->is_too_large) {
|
||||
|
|
@ -160,7 +160,7 @@ public function convertToDirectory()
|
|||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('storageCountsChanged')->to(Storage::class);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ public function loadStorageOnServer()
|
|||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('storageCountsChanged')->to(Storage::class);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +207,7 @@ public function convertToFile()
|
|||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('storageCountsChanged')->to(Storage::class);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -242,7 +242,7 @@ public function delete($password, $selectedActions = [])
|
|||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('storageCountsChanged')->to(Storage::class);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -308,10 +308,10 @@ public function render()
|
|||
{
|
||||
return view('livewire.project.service.file-storage', [
|
||||
'directoryDeletionCheckboxes' => [
|
||||
['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'],
|
||||
['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permanently deleted from the server.'],
|
||||
],
|
||||
'fileDeletionCheckboxes' => [
|
||||
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'],
|
||||
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted from the server.'],
|
||||
],
|
||||
'hostFileDeletionCheckboxes' => [
|
||||
['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'],
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@
|
|||
use App\Actions\Docker\GetContainersStatus;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
|
@ -169,6 +172,29 @@ public function restart()
|
|||
}
|
||||
}
|
||||
|
||||
public function removeSelectedResourceContainer(): void
|
||||
{
|
||||
$resource = $this->selectedResource();
|
||||
if (! $resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
StopServiceApplication::run($resource, true, true);
|
||||
$this->dispatch('success', 'Container removed.');
|
||||
}
|
||||
|
||||
private function selectedResource(): ServiceApplication|ServiceDatabase|null
|
||||
{
|
||||
$uuid = data_get($this->parameters, 'stack_service_uuid');
|
||||
if (! $uuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->service->applications()->whereUuid($uuid)->first()
|
||||
?? $this->service->databases()->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
public function pullAndRestartEvent()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
80
app/Livewire/Project/Service/ImportBackup.php
Normal file
80
app/Livewire/Project/Service/ImportBackup.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
||||
class ImportBackup extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Service $service;
|
||||
|
||||
public Collection $databases;
|
||||
|
||||
public ?ServiceDatabase $selectedDatabase = null;
|
||||
|
||||
public string $selectedDatabaseUuid = '';
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public function mount(): mixed
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$project = currentTeam()->projects()->whereUuid($this->parameters['project_uuid'])->firstOrFail();
|
||||
$environment = $project->environments()->whereUuid($this->parameters['environment_uuid'])->firstOrFail();
|
||||
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
|
||||
$this->authorize('update', $this->service);
|
||||
|
||||
$this->databases = $this->service->databases
|
||||
->filter(fn (ServiceDatabase $database): bool => $this->supportsImport($database))
|
||||
->values();
|
||||
|
||||
$databaseUuid = request()->route('stack_service_uuid');
|
||||
if ($databaseUuid) {
|
||||
$selectedDatabase = $this->databases->firstWhere('uuid', $databaseUuid);
|
||||
abort_unless($selectedDatabase instanceof ServiceDatabase, 404);
|
||||
$this->authorize('update', $selectedDatabase);
|
||||
$this->selectedDatabase = $selectedDatabase;
|
||||
$this->selectedDatabaseUuid = $selectedDatabase->uuid;
|
||||
|
||||
if (request()->routeIs('project.service.database.import')) {
|
||||
return redirect()->route('project.service.import-backup.database', $this->parameters);
|
||||
}
|
||||
} elseif ($this->databases->count() === 1) {
|
||||
return redirect()->route('project.service.import-backup.database', [
|
||||
...$this->parameters,
|
||||
'stack_service_uuid' => $this->databases->first()->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function updatedSelectedDatabaseUuid(): mixed
|
||||
{
|
||||
$database = $this->databases->firstWhere('uuid', $this->selectedDatabaseUuid);
|
||||
abort_unless($database instanceof ServiceDatabase, 404);
|
||||
$this->authorize('update', $database);
|
||||
|
||||
return redirect()->route('project.service.import-backup.database', [
|
||||
...$this->parameters,
|
||||
'stack_service_uuid' => $database->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.service.import-backup');
|
||||
}
|
||||
|
||||
private function supportsImport(ServiceDatabase $database): bool
|
||||
{
|
||||
return str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo']);
|
||||
}
|
||||
}
|
||||
|
|
@ -59,8 +59,6 @@ class Index extends Component
|
|||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public bool $isImportSupported = false;
|
||||
|
||||
// Application-specific properties
|
||||
public $docker_cleanup = true;
|
||||
|
||||
|
|
@ -101,10 +99,27 @@ class Index extends Component
|
|||
'isStripprefixEnabled' => 'nullable|boolean',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
public function mount(?ServiceApplication $serviceApplication = null)
|
||||
{
|
||||
try {
|
||||
$this->services = collect([]);
|
||||
if ($serviceApplication) {
|
||||
$this->service = $serviceApplication->service;
|
||||
$this->authorize('view', $this->service);
|
||||
$this->parameters = [
|
||||
'project_uuid' => $this->service->environment->project->uuid,
|
||||
'environment_uuid' => $this->service->environment->uuid,
|
||||
'service_uuid' => $this->service->uuid,
|
||||
'stack_service_uuid' => $serviceApplication->uuid,
|
||||
];
|
||||
$this->query = request()->query();
|
||||
$this->serviceApplication = $serviceApplication;
|
||||
$this->resourceType = 'application';
|
||||
$this->initializeApplicationProperties();
|
||||
$this->s3s = currentTeam()->s3s;
|
||||
|
||||
return;
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->query = request()->query();
|
||||
$this->currentRoute = request()->route()->getName();
|
||||
|
|
@ -153,10 +168,6 @@ private function initializeDatabaseProperties(): void
|
|||
$this->refreshFileStorages();
|
||||
$this->syncDatabaseData(false);
|
||||
|
||||
// Check if import is supported for this database type
|
||||
$dbType = $this->serviceDatabase->databaseType();
|
||||
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
|
||||
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
|
||||
}
|
||||
|
||||
private function syncDatabaseData(bool $toModel = false): void
|
||||
|
|
@ -356,7 +367,7 @@ private function syncApplicationData(bool $toModel = false): void
|
|||
if ($toModel) {
|
||||
$this->serviceApplication->human_name = $this->humanName;
|
||||
$this->serviceApplication->description = $this->description;
|
||||
$this->serviceApplication->fqdn = $this->fqdn;
|
||||
$this->serviceApplication->setEditableUrls($this->fqdn);
|
||||
$this->serviceApplication->image = $this->image;
|
||||
$this->serviceApplication->exclude_from_status = $this->excludeFromStatus;
|
||||
$this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
|
|
@ -365,7 +376,7 @@ private function syncApplicationData(bool $toModel = false): void
|
|||
} else {
|
||||
$this->humanName = $this->serviceApplication->human_name;
|
||||
$this->description = $this->serviceApplication->description;
|
||||
$this->fqdn = $this->serviceApplication->fqdn;
|
||||
$this->fqdn = $this->serviceApplication->url;
|
||||
$this->image = $this->serviceApplication->image;
|
||||
$this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false);
|
||||
$this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false);
|
||||
|
|
@ -428,7 +439,7 @@ public function deleteApplication($password, $selectedActions = [])
|
|||
$this->serviceApplication->delete();
|
||||
$this->dispatch('success', 'Application deleted.');
|
||||
|
||||
return redirect()->route('project.service.configuration', $this->parameters);
|
||||
return redirectRoute($this, 'project.service.configuration', $this->parameters);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -462,7 +473,7 @@ public function convertToDatabase()
|
|||
$serviceApplication->delete();
|
||||
});
|
||||
|
||||
return redirect()->route('project.service.configuration', $redirectParams);
|
||||
return redirectRoute($this, 'project.service.configuration', $redirectParams);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -491,6 +502,10 @@ public function cancelRemovePort()
|
|||
public function submitApplication()
|
||||
{
|
||||
try {
|
||||
$persistedApplication = $this->serviceApplication->fresh();
|
||||
$previousEditableUrls = $persistedApplication->url;
|
||||
$previousFqdn = $persistedApplication->fqdn;
|
||||
$previousPortOverrides = $persistedApplication->domain_port_overrides;
|
||||
$this->authorize('update', $this->serviceApplication);
|
||||
$this->validate([
|
||||
'fqdn' => ValidationPatterns::applicationDomainRules(),
|
||||
|
|
@ -520,28 +535,21 @@ public function submitApplication()
|
|||
$requiredPort = $this->serviceApplication->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
$fqdns = str($this->fqdn)->trim()->explode(',');
|
||||
$missingPort = false;
|
||||
|
||||
foreach ($fqdns as $fqdn) {
|
||||
$fqdn = trim($fqdn);
|
||||
if (empty($fqdn)) {
|
||||
foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) {
|
||||
$fqdn = trim((string) $fqdn);
|
||||
if ($fqdn === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$port = ServiceApplication::extractPortFromUrl($fqdn);
|
||||
if ($port === null) {
|
||||
$missingPort = true;
|
||||
break;
|
||||
if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
$this->serviceApplication->fqdn = $previousFqdn;
|
||||
$this->serviceApplication->domain_port_overrides = $previousPortOverrides;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingPort) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->forceRemovePort = false;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ class Status extends Component
|
|||
{
|
||||
public Service $service;
|
||||
|
||||
public ?string $selectedResourceUuid = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->selectedResourceUuid = request()->route('stack_service_uuid');
|
||||
}
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
|
@ -27,6 +34,11 @@ public function refreshStatus(): void
|
|||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.service.status');
|
||||
$selectedResource = $this->selectedResourceUuid
|
||||
? $this->service->applications->firstWhere('uuid', $this->selectedResourceUuid)
|
||||
?? $this->service->databases->firstWhere('uuid', $this->selectedResourceUuid)
|
||||
: null;
|
||||
|
||||
return view('livewire.project.service.status', compact('selectedResource'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Livewire\Project\Shared\Storages\All as StorageList;
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
|
|
@ -51,7 +52,7 @@ public function getListeners()
|
|||
|
||||
return [
|
||||
"echo-private:team.{$teamId},FileStorageChanged" => 'refreshStoragesFromEvent',
|
||||
'refreshStorages',
|
||||
'storageCountsChanged' => 'refreshStorages',
|
||||
'addNewVolume',
|
||||
];
|
||||
}
|
||||
|
|
@ -87,11 +88,17 @@ public function refreshStoragesFromEvent()
|
|||
|
||||
public function refreshStorages()
|
||||
{
|
||||
$hadVolumes = $this->cachedVolumeCount > 0;
|
||||
|
||||
// Avoid loading full volume models onto this parent (child All owns that snapshot).
|
||||
$this->resource->unsetRelation('persistentStorages');
|
||||
$this->loadVolumeCount();
|
||||
$this->loadFileStorageMetaCounts();
|
||||
$this->loadFileStorageForActiveTab();
|
||||
|
||||
if ($this->activeTab === 'volumes' && $hadVolumes && $this->cachedVolumeCount > 0) {
|
||||
$this->dispatch('refreshVolumeList')->to(StorageList::class);
|
||||
}
|
||||
}
|
||||
|
||||
public function setActiveTab(string $tab): void
|
||||
|
|
@ -223,7 +230,6 @@ public function submitPersistentVolume()
|
|||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', 'Volume added successfully');
|
||||
$this->dispatch('closeStorageModal', 'volume');
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -258,7 +264,6 @@ public function submitFileStorage()
|
|||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', 'File mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'file');
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -293,7 +298,6 @@ public function submitHostFileStorage()
|
|||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', 'Host file mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'host-file');
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -332,7 +336,6 @@ public function submitFileStorageDirectory()
|
|||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', 'Directory mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'directory');
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
namespace App\Livewire\Project\Service\VolumeBackup;
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\VolumeBackupJob;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -20,14 +23,70 @@ class Index extends Component
|
|||
|
||||
public string $search = '';
|
||||
|
||||
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
|
||||
public bool $scheduleModalOpen = false;
|
||||
|
||||
public function mount(): void
|
||||
public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null;
|
||||
|
||||
public ?ScheduledVolumeBackup $selectedVolumeBackup = null;
|
||||
|
||||
public ?Collection $s3s = null;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
$this->service = $this->findService();
|
||||
$teamId = currentTeam()->id;
|
||||
|
||||
return [
|
||||
'refreshVolumeBackups' => '$refresh',
|
||||
'modalClosed' => 'closeScheduleModal',
|
||||
"echo-private:team.{$teamId},BackupCreated" => '$refresh',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(?Service $service = null): void
|
||||
{
|
||||
$this->service = $service ?? $this->findService();
|
||||
$this->authorize('view', $this->service);
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->search = request()->string('search')->toString();
|
||||
|
||||
}
|
||||
|
||||
public function openSchedule(string $backupUuid): void
|
||||
{
|
||||
$this->loadSelectedSchedule($backupUuid);
|
||||
$this->s3s = currentTeam()->s3s;
|
||||
$this->scheduleModalOpen = true;
|
||||
}
|
||||
|
||||
public function closeScheduleModal(): void
|
||||
{
|
||||
$this->scheduleModalOpen = false;
|
||||
$this->selectedDatabaseBackup = null;
|
||||
$this->selectedVolumeBackup = null;
|
||||
}
|
||||
|
||||
public function backupNow(string $type, string $backupUuid): void
|
||||
{
|
||||
try {
|
||||
if ($type === 'database') {
|
||||
$this->loadSelectedSchedule($backupUuid);
|
||||
abort_unless($this->selectedDatabaseBackup, 404);
|
||||
$this->authorize('manageBackups', $this->selectedDatabaseBackup->database);
|
||||
DatabaseBackupJob::dispatch($this->selectedDatabaseBackup);
|
||||
} else {
|
||||
abort_unless($type === 'storage', 404);
|
||||
$this->loadSelectedSchedule($backupUuid);
|
||||
abort_unless($this->selectedVolumeBackup, 404);
|
||||
$this->authorize('update', $this->selectedVolumeBackup->targetResource());
|
||||
VolumeBackupJob::dispatch($this->selectedVolumeBackup);
|
||||
}
|
||||
|
||||
$this->selectedDatabaseBackup = null;
|
||||
$this->selectedVolumeBackup = null;
|
||||
$this->dispatch('success', 'Backup queued.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
|
|
@ -68,4 +127,24 @@ private function findService(): Service
|
|||
->where('uuid', request()->route('service_uuid'))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function loadSelectedSchedule(string $backupUuid): void
|
||||
{
|
||||
$this->selectedDatabaseBackup = ScheduledDatabaseBackup::query()
|
||||
->with('database')
|
||||
->whereUuid($backupUuid)
|
||||
->where('database_type', (new ServiceDatabase)->getMorphClass())
|
||||
->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id))
|
||||
->first();
|
||||
|
||||
if ($this->selectedDatabaseBackup) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedVolumeBackup = ScheduledVolumeBackup::query()
|
||||
->with('backupable.resource')
|
||||
->whereUuid($backupUuid)
|
||||
->forService($this->service)
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class Show extends Component
|
|||
|
||||
public string $section = 'general';
|
||||
|
||||
public function mount(): void
|
||||
public function mount(): mixed
|
||||
{
|
||||
$project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
|
||||
|
|
@ -43,6 +43,10 @@ public function mount(): void
|
|||
'project.service.volume-backups.danger' => 'danger',
|
||||
default => 'general',
|
||||
};
|
||||
|
||||
$routeParameters = collect($this->parameters)->except('backup_uuid')->all();
|
||||
|
||||
return redirect()->route('project.service.volume-backups.index', $routeParameters);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
|
|
|
|||
|
|
@ -106,14 +106,13 @@ public function delete($password, $selectedActions = [])
|
|||
|
||||
try {
|
||||
$this->authorize('delete', $this->resource);
|
||||
$this->resource->delete();
|
||||
DeleteResourceJob::dispatch(
|
||||
$this->resource,
|
||||
$this->delete_volumes,
|
||||
$this->delete_connected_networks,
|
||||
$this->delete_configurations,
|
||||
$this->docker_cleanup
|
||||
);
|
||||
)->afterResponse();
|
||||
|
||||
return redirectRoute($this, 'project.resource.index', [
|
||||
'project_uuid' => $this->projectUuid,
|
||||
|
|
|
|||
|
|
@ -818,19 +818,21 @@ private function formatEnvironmentVariables($variables)
|
|||
{
|
||||
$isMember = auth()->user()?->isMember();
|
||||
|
||||
return $variables->map(function ($item) use ($isMember) {
|
||||
if ($isMember) {
|
||||
return "$item->key=(Hidden, only admins can view)";
|
||||
}
|
||||
if ($item->is_shown_once) {
|
||||
return "$item->key=(Locked Secret, delete and add again to change)";
|
||||
}
|
||||
if ($item->is_multiline) {
|
||||
return "$item->key=(Multiline environment variable, edit in normal view)";
|
||||
}
|
||||
return $variables
|
||||
->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key))
|
||||
->map(function ($item) use ($isMember) {
|
||||
if ($isMember) {
|
||||
return "$item->key=(Hidden, only admins can view)";
|
||||
}
|
||||
if ($item->is_shown_once) {
|
||||
return "$item->key=(Locked Secret, delete and add again to change)";
|
||||
}
|
||||
if ($item->is_multiline) {
|
||||
return "$item->key=(Multiline environment variable, edit in normal view)";
|
||||
}
|
||||
|
||||
return "$item->key=$item->value";
|
||||
})->join("\n");
|
||||
return "$item->key=$item->value";
|
||||
})->join("\n");
|
||||
}
|
||||
|
||||
public function switch()
|
||||
|
|
@ -908,8 +910,7 @@ private function handleBulkSubmit()
|
|||
$deletedCount = $this->deleteRemovedVariables(false, $variables);
|
||||
if ($deletedCount > 0) {
|
||||
$changesMade = true;
|
||||
} elseif ($deletedCount === 0 && $this->resource->environment_variables()->whereNotIn('key', array_keys($variables))->exists()) {
|
||||
// If we tried to delete but couldn't (due to Docker Compose), mark as error
|
||||
} elseif ($deletedCount < 0) {
|
||||
$errorOccurred = true;
|
||||
}
|
||||
|
||||
|
|
@ -926,8 +927,7 @@ private function handleBulkSubmit()
|
|||
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
|
||||
if ($deletedPreviewCount > 0) {
|
||||
$changesMade = true;
|
||||
} elseif ($deletedPreviewCount === 0 && $this->resource->environment_variables_preview()->whereNotIn('key', array_keys($previewVariables))->exists()) {
|
||||
// If we tried to delete but couldn't (due to Docker Compose), mark as error
|
||||
} elseif ($deletedPreviewCount < 0) {
|
||||
$errorOccurred = true;
|
||||
}
|
||||
|
||||
|
|
@ -988,6 +988,12 @@ private function deleteRemovedVariables($isPreview, $variables)
|
|||
// Get all environment variables that will be deleted
|
||||
$variablesToDelete = $this->resource->$method()->whereNotIn('key', array_keys($variables))->get();
|
||||
|
||||
// Generated Compose variables are managed by Coolify and must survive a bulk
|
||||
// replacement even when they are omitted from the pasted environment file.
|
||||
$variablesToDelete = $variablesToDelete->reject(
|
||||
fn (EnvironmentVariable $environmentVariable): bool => $this->isProtectedEnvironmentVariable($environmentVariable->key)
|
||||
);
|
||||
|
||||
// If there are no variables to delete, return 0
|
||||
if ($variablesToDelete->isEmpty()) {
|
||||
return 0;
|
||||
|
|
@ -1001,13 +1007,13 @@ private function deleteRemovedVariables($isPreview, $variables)
|
|||
if ($isUsed) {
|
||||
$this->dispatch('error', "Cannot delete environment variable '{$envVar->key}' <br><br>Please remove it from the Docker Compose file first.");
|
||||
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, no variables are used in Docker Compose, so we can delete them
|
||||
$this->resource->$method()->whereNotIn('key', array_keys($variables))->delete();
|
||||
$this->resource->$method()->whereKey($variablesToDelete->modelKeys())->delete();
|
||||
|
||||
return $variablesToDelete->count();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,8 @@ public function refresh()
|
|||
*/
|
||||
public function loadValues(): void
|
||||
{
|
||||
$this->authorize('update', $this->env);
|
||||
|
||||
if ($this->valuesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -162,7 +164,7 @@ public function loadValues(): void
|
|||
$this->valuesLoaded = true;
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
|
||||
|
|
|
|||
|
|
@ -17,12 +17,15 @@
|
|||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class GetLogs extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public const MAX_LOG_LINES = 50000;
|
||||
|
||||
public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
|
@ -82,6 +85,10 @@ public function mount()
|
|||
public function instantSave()
|
||||
{
|
||||
if (! is_null($this->resource)) {
|
||||
if (auth()->user()->cannot('update', $this->resource)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
$this->resource->settings->is_include_timestamps = $this->showTimeStamps;
|
||||
$this->resource->settings->save();
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false): void
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ public function submit()
|
|||
}
|
||||
}
|
||||
|
||||
public function saveScheduledTask()
|
||||
private function saveScheduledTask(): void
|
||||
{
|
||||
try {
|
||||
$task = new ScheduledTask;
|
||||
|
|
@ -128,7 +128,7 @@ public function saveScheduledTask()
|
|||
$this->dispatch('refreshTasks');
|
||||
$this->dispatch('success', 'Scheduled task added.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
@ -169,9 +169,9 @@ public function delete()
|
|||
$this->task->delete();
|
||||
|
||||
if ($this->type === 'application') {
|
||||
return redirect()->route('project.application.scheduled-tasks.show', $this->parameters);
|
||||
return redirectRoute($this, 'project.application.scheduled-tasks.show', $this->parameters);
|
||||
} else {
|
||||
return redirect()->route('project.service.scheduled-tasks.show', $this->parameters);
|
||||
return redirectRoute($this, 'project.service.scheduled-tasks.show', $this->parameters);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Livewire\Project\Service\Storage as StorageComponent;
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
|
|
@ -44,7 +45,7 @@ class All extends Component
|
|||
|
||||
public bool $deleteDockerVolume = false;
|
||||
|
||||
protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList'];
|
||||
protected $listeners = ['refreshVolumeList' => 'refreshList', 'refreshVolumeBackups' => 'refreshList'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
|
|
@ -163,7 +164,7 @@ public function delete(int $storageId, $password = '', $selectedActions = [])
|
|||
|
||||
$storage->delete();
|
||||
$this->refreshList();
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('storageCountsChanged')->to(StorageComponent::class);
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Livewire\Project\Service\Storage as StorageComponent;
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
|
|
@ -192,7 +193,7 @@ public function delete($password, $selectedActions = [])
|
|||
}
|
||||
|
||||
$this->storage->delete();
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('storageCountsChanged')->to(StorageComponent::class);
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ public function getListeners()
|
|||
|
||||
public function loadScripts()
|
||||
{
|
||||
$this->authorize('viewAny', CloudInitScript::class);
|
||||
|
||||
CloudInitScript::ownedByCurrentTeam()
|
||||
->whereNull('uuid')
|
||||
->get()
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ private function validateToken(string $provider, string $token): bool
|
|||
|
||||
public function addToken()
|
||||
{
|
||||
$this->authorize('create', CloudProviderToken::class);
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ private function syncData(bool $toModel = false): void
|
|||
// Sync FROM model (on load/refresh)
|
||||
$this->name = $this->private_key->name;
|
||||
$this->description = $this->private_key->description;
|
||||
$this->privateKeyValue = $this->private_key->private_key;
|
||||
$this->privateKeyValue = auth()->user()->can('update', $this->private_key)
|
||||
? $this->private_key->private_key
|
||||
: '';
|
||||
$this->isGitRelated = $this->private_key->is_git_related;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,10 +42,9 @@ public function mount(string $server_uuid)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->validate();
|
||||
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
|
||||
$this->server->settings->dynamic_timeout = $this->dynamicTimeout;
|
||||
|
|
@ -67,6 +66,7 @@ public function syncData(bool $toModel = false)
|
|||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Server updated.');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -81,6 +81,7 @@ public function submit()
|
|||
$this->serverDiskUsageCheckFrequency = $this->server->settings->getOriginal('server_disk_usage_check_frequency');
|
||||
throw new \Exception('Invalid Cron / Human expression for Disk Usage Check Frequency.');
|
||||
}
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Server updated.');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -97,10 +97,9 @@ public function mount(string $server_uuid)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->validate();
|
||||
$this->server->settings->force_docker_cleanup = $this->forceDockerCleanup;
|
||||
$this->server->settings->docker_cleanup_frequency = $this->dockerCleanupFrequency;
|
||||
|
|
@ -122,6 +121,7 @@ public function syncData(bool $toModel = false)
|
|||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Server updated.');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -147,6 +147,7 @@ public function submit()
|
|||
$this->dockerCleanupFrequency = $this->server->settings->getOriginal('docker_cleanup_frequency');
|
||||
throw new \Exception('Invalid Cron / Human expression for Docker Cleanup Frequency.');
|
||||
}
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Server updated.');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public function mount(string $server_uuid)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncDataNewRelic(bool $toModel = false)
|
||||
private function syncDataNewRelic(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->server->settings->is_logdrain_newrelic_enabled = $this->isLogDrainNewRelicEnabled;
|
||||
|
|
@ -65,7 +65,7 @@ public function syncDataNewRelic(bool $toModel = false)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncDataAxiom(bool $toModel = false)
|
||||
private function syncDataAxiom(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->server->settings->is_logdrain_axiom_enabled = $this->isLogDrainAxiomEnabled;
|
||||
|
|
@ -78,7 +78,7 @@ public function syncDataAxiom(bool $toModel = false)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncDataCustom(bool $toModel = false)
|
||||
private function syncDataCustom(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->server->settings->is_logdrain_custom_enabled = $this->isLogDrainCustomEnabled;
|
||||
|
|
@ -91,7 +91,7 @@ public function syncDataCustom(bool $toModel = false)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false, ?string $type = null)
|
||||
private function syncData(bool $toModel = false, ?string $type = null): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->customValidation();
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ public function changeProxy()
|
|||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->server->proxy = null;
|
||||
$this->server->detected_traefik_version = null;
|
||||
$this->server->traefik_outdated_info = null;
|
||||
$this->server->save();
|
||||
|
||||
$this->dispatch('reloadWindow');
|
||||
|
|
|
|||
|
|
@ -62,10 +62,9 @@ public function toggleTerminal($password, $selectedActions = [])
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->validate();
|
||||
// No other fields to sync for terminal access
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -54,10 +54,9 @@ public function mount()
|
|||
$this->syncData();
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->validate();
|
||||
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
|
||||
$this->server->settings->sentinel_token = $this->sentinelToken;
|
||||
|
|
@ -145,6 +144,7 @@ public function regenerateSentinelToken()
|
|||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -155,6 +155,7 @@ public function submit()
|
|||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
$this->restartSentinel();
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -230,12 +230,10 @@ public function timezones(): array
|
|||
->toArray();
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
$this->authorize('update', $this->server);
|
||||
$foundServer = Server::where('ip', $this->ip)
|
||||
->where('id', '!=', $this->server->id)
|
||||
->first();
|
||||
|
|
@ -363,6 +361,7 @@ public function validateServer($install = true)
|
|||
public function checkLocalhostConnection()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if ($uptime) {
|
||||
|
|
@ -479,6 +478,7 @@ public function regenerateSentinelToken()
|
|||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -694,6 +694,7 @@ public function refreshServerMetadata(): void
|
|||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Server settings updated.');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -29,10 +29,9 @@ public function mount(string $server_uuid)
|
|||
}
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
|
||||
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
|
||||
$this->server->settings->save();
|
||||
|
|
@ -45,6 +44,7 @@ public function syncData(bool $toModel = false)
|
|||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Swarm settings updated.');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ class ValidateAndInstall extends Component
|
|||
|
||||
public function init(int $data = 0)
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
if (! $this->server->canBeValidated()) {
|
||||
$this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.';
|
||||
$this->server->update([
|
||||
|
|
@ -160,6 +162,8 @@ public function validateConnection()
|
|||
|
||||
public function validateOS()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
$this->supported_os_type = $this->server->validateOS();
|
||||
if (! $this->supported_os_type) {
|
||||
$this->error = 'Server OS type is not supported. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
|
||||
|
|
@ -174,6 +178,8 @@ public function validateOS()
|
|||
|
||||
public function validatePrerequisites()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
$validationResult = $this->server->validatePrerequisites();
|
||||
$this->prerequisites_installed = $validationResult['success'];
|
||||
if (! $validationResult['success']) {
|
||||
|
|
@ -212,6 +218,8 @@ public function validatePrerequisites()
|
|||
|
||||
public function validateDockerEngine()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
$this->docker_installed = $this->server->validateDockerEngine();
|
||||
$this->docker_compose_installed = $this->server->validateDockerCompose();
|
||||
if (! $this->docker_installed || ! $this->docker_compose_installed) {
|
||||
|
|
@ -248,6 +256,8 @@ public function validateDockerEngine()
|
|||
|
||||
public function validateDockerVersion()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
if ($this->server->isSwarm()) {
|
||||
$swarmInstalled = $this->server->validateDockerSwarm();
|
||||
if ($swarmInstalled) {
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ public function mount()
|
|||
$this->testEmailAddress = auth()->user()->email;
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -122,13 +122,6 @@ public function updatedHtmlUrl(): void
|
|||
}
|
||||
}
|
||||
|
||||
public function boot()
|
||||
{
|
||||
if ($this->github_app) {
|
||||
$this->github_app->makeVisible(['client_secret', 'webhook_secret']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync data between component properties and model
|
||||
*
|
||||
|
|
@ -170,8 +163,9 @@ private function syncData(bool $toModel = false): void
|
|||
$this->appId = $this->github_app->app_id;
|
||||
$this->installationId = $this->github_app->installation_id;
|
||||
$this->clientId = $this->github_app->client_id;
|
||||
$this->clientSecret = $this->github_app->client_secret;
|
||||
$this->webhookSecret = $this->github_app->webhook_secret;
|
||||
$canUpdate = auth()->user()->can('update', $this->github_app);
|
||||
$this->clientSecret = $canUpdate ? $this->github_app->client_secret : null;
|
||||
$this->webhookSecret = $canUpdate ? $this->github_app->webhook_secret : null;
|
||||
$this->isSystemWide = $this->github_app->is_system_wide;
|
||||
$this->privateKeyId = $this->github_app->private_key_id;
|
||||
$this->contents = $this->github_app->contents;
|
||||
|
|
@ -231,7 +225,7 @@ public function checkPermissions()
|
|||
syncGithubAppName($this->github_app);
|
||||
|
||||
GithubAppPermissionJob::dispatchSync($this->github_app);
|
||||
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
$this->github_app->refresh();
|
||||
$this->syncData(false);
|
||||
$this->isConnected = $this->github_app->isConnected();
|
||||
$this->name = str($this->github_app->name)->kebab();
|
||||
|
|
@ -305,7 +299,7 @@ public function mount()
|
|||
try {
|
||||
$github_app_uuid = request()->github_app_uuid;
|
||||
$this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail();
|
||||
$this->github_app->makeVisible(['client_secret', 'webhook_secret']);
|
||||
$this->authorize('view', $this->github_app);
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
|
||||
|
||||
$this->applications = $this->github_app->applications;
|
||||
|
|
@ -420,7 +414,6 @@ public function submit()
|
|||
try {
|
||||
$this->authorize('update', $this->github_app);
|
||||
|
||||
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
$this->organization = normalizeGithubOrganization($this->organization);
|
||||
$this->apiUrl = filled($this->apiUrl)
|
||||
? $this->apiUrl
|
||||
|
|
@ -442,7 +435,6 @@ public function createGithubAppManually()
|
|||
{
|
||||
$this->authorize('update', $this->github_app);
|
||||
|
||||
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
$this->github_app->app_id = 1234567890;
|
||||
$this->github_app->installation_id = 1234567890;
|
||||
$this->github_app->save();
|
||||
|
|
@ -457,8 +449,6 @@ public function instantSave()
|
|||
try {
|
||||
$this->authorize('update', $this->github_app);
|
||||
|
||||
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
|
||||
$this->syncData(true);
|
||||
$this->github_app->save();
|
||||
$this->isConnected = $this->github_app->isConnected();
|
||||
|
|
@ -475,7 +465,6 @@ public function delete()
|
|||
|
||||
if ($this->github_app->applications->isNotEmpty()) {
|
||||
$this->dispatch('error', 'This source is being used by an application. Please delete all applications first.');
|
||||
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -484,7 +473,7 @@ public function delete()
|
|||
// @can and canGate checks against a deleted model (null team_id TypeError).
|
||||
$this->github_app = null;
|
||||
|
||||
return redirect()->route('source.all');
|
||||
return redirectRoute($this, 'source.all');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ public function delete()
|
|||
// @can and canGate checks against a deleted model (null team_id TypeError).
|
||||
$this->gitlab_app = null;
|
||||
|
||||
return redirect()->route('source.all');
|
||||
return redirectRoute($this, 'source.all');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public function delete()
|
|||
|
||||
$this->storage->delete();
|
||||
|
||||
return redirect()->route('storage.index');
|
||||
return redirectRoute($this, 'storage.index');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiff;
|
||||
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasMetrics;
|
||||
|
|
@ -135,6 +137,7 @@ class Application extends BaseModel
|
|||
'description',
|
||||
'fqdn',
|
||||
'noindex_domains',
|
||||
'domain_port_overrides',
|
||||
'git_repository',
|
||||
'git_branch',
|
||||
'git_commit_sha',
|
||||
|
|
@ -213,6 +216,8 @@ class Application extends BaseModel
|
|||
'last_online_at',
|
||||
'restart_count',
|
||||
'max_restart_count',
|
||||
'restart_limit_reached',
|
||||
'container_present',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
'uuid',
|
||||
|
|
@ -244,6 +249,7 @@ class Application extends BaseModel
|
|||
'docker_compose_raw',
|
||||
'custom_labels',
|
||||
'domain_dns_statuses',
|
||||
'domain_port_overrides',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
@ -256,8 +262,11 @@ protected function casts(): array
|
|||
'manual_webhook_secret_gitea' => 'encrypted',
|
||||
'noindex_domains' => 'array',
|
||||
'domain_dns_statuses' => 'array',
|
||||
'domain_port_overrides' => 'array',
|
||||
'restart_count' => 'integer',
|
||||
'max_restart_count' => 'integer',
|
||||
'restart_limit_reached' => 'boolean',
|
||||
'container_present' => 'boolean',
|
||||
'last_restart_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
|
@ -282,6 +291,9 @@ protected static function booted()
|
|||
if ($application->fqdn === '') {
|
||||
$application->fqdn = null;
|
||||
}
|
||||
$normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides);
|
||||
$application->fqdn = $normalized['fqdn'];
|
||||
$application->domain_port_overrides = $normalized['overrides'];
|
||||
$payload['fqdn'] = $application->fqdn;
|
||||
$application->syncNoindexDomains();
|
||||
}
|
||||
|
|
@ -605,36 +617,8 @@ public function link()
|
|||
public function stoppedAfterRestartLimit(): bool
|
||||
{
|
||||
return str($this->status)->startsWith('exited')
|
||||
&& ($this->restart_count ?? 0) > 0
|
||||
&& ($this->max_restart_count ?? 0) > 0
|
||||
&& $this->restart_count >= $this->max_restart_count
|
||||
&& $this->last_restart_type === 'crash';
|
||||
}
|
||||
|
||||
public function taskLink($task_uuid)
|
||||
{
|
||||
if (data_get($this, 'environment.project.uuid')) {
|
||||
$route = route('project.application.scheduled-tasks', [
|
||||
'project_uuid' => data_get($this, 'environment.project.uuid'),
|
||||
'environment_uuid' => data_get($this, 'environment.uuid'),
|
||||
'application_uuid' => data_get($this, 'uuid'),
|
||||
'task_uuid' => $task_uuid,
|
||||
]);
|
||||
$settings = instanceSettings();
|
||||
if (data_get($settings, 'fqdn')) {
|
||||
$url = Url::fromString($route);
|
||||
$url = $url->withPort(null);
|
||||
$fqdn = data_get($settings, 'fqdn');
|
||||
$fqdn = str_replace(['http://', 'https://'], '', $fqdn);
|
||||
$url = $url->withHost($fqdn);
|
||||
|
||||
return $url->__toString();
|
||||
}
|
||||
|
||||
return $route;
|
||||
}
|
||||
|
||||
return null;
|
||||
&& $this->container_present === true
|
||||
&& $this->restart_limit_reached === true;
|
||||
}
|
||||
|
||||
public function settings()
|
||||
|
|
@ -730,7 +714,7 @@ public function gitCommits(): Attribute
|
|||
);
|
||||
}
|
||||
|
||||
public function gitCommitLink($link): string
|
||||
public function gitCommitLink($link): ?string
|
||||
{
|
||||
if (! is_null(data_get($this, 'source.html_url')) && ! is_null(data_get($this, 'git_repository')) && ! is_null(data_get($this, 'git_branch'))) {
|
||||
if (str($this->source->html_url)->contains('bitbucket')) {
|
||||
|
|
@ -747,6 +731,10 @@ public function gitCommitLink($link): string
|
|||
$git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH);
|
||||
}
|
||||
|
||||
if (! filter_var($git_repository, FILTER_VALIDATE_URL)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = Url::fromString(Str::replaceEnd('.git', '', $git_repository));
|
||||
$url = $url->withUserInfo('');
|
||||
$commitPath = str($git_repository)->contains('bitbucket') ? 'commits' : 'commit';
|
||||
|
|
@ -972,6 +960,46 @@ public function main_port()
|
|||
return $this->settings->is_static ? [80] : $this->ports_exposes_array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function availableInternalPorts(): array
|
||||
{
|
||||
$ports = collect($this->settings?->is_static ? [80] : $this->ports_exposes_array)
|
||||
->filter(fn (mixed $port): bool => is_numeric($port) && (int) $port > 0)
|
||||
->map(fn (mixed $port): int => (int) $port);
|
||||
|
||||
foreach ($this->domain_port_overrides ?? [] as $port) {
|
||||
if (is_numeric($port) && (int) $port > 0) {
|
||||
$ports->push((int) $port);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (explode(',', (string) $this->fqdn) as $url) {
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$legacyPort = DomainUrlParts::split($url)['port'] ?? '';
|
||||
if ($legacyPort !== '' && is_numeric($legacyPort) && (int) $legacyPort > 0) {
|
||||
$ports->push((int) $legacyPort);
|
||||
}
|
||||
}
|
||||
|
||||
return $ports->unique()->sort()->values()->all();
|
||||
}
|
||||
|
||||
public function portRequiresConfirmation(?int $port): bool
|
||||
{
|
||||
if ($port === null || $port <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! in_array($port, $this->availableInternalPorts(), true);
|
||||
}
|
||||
|
||||
public function detectPortFromEnvironment(?bool $isPreview = false): ?int
|
||||
{
|
||||
$envVars = $isPreview
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue