Merge branch 'next' into shadow/add-shared-server-env

This commit is contained in:
ShadowArcanist authored and GitHub committed 2026-01-14 05:26:06 +01:00
commit e412b57df7
227 files changed
+9960 -15827

No files matched your search

@@ -121,6 +121,25 @@ class Show extends Component
return sanitizeLogsForExport($logs);
}
public function downloadAllLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
->map(function ($line) {
$prefix = '';
if ($line['hidden']) {
$prefix = '[DEBUG] ';
}
if (isset($line['command']) && $line['command']) {
$prefix .= '[CMD]: ';
}
return $line['timestamp'].' '.$prefix.trim($line['line']);
})
->join("\n");
return sanitizeLogsForExport($logs);
}
public function render()
{
return view('livewire.project.application.deployment.show');
+5 -71
View File
@@ -232,8 +232,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'gitRepository.required' => 'The Git Repository field is required.',
'gitBranch.required' => 'The Git Branch field is required.',
'buildPack.required' => 'The Build Pack field is required.',
@@ -558,8 +556,11 @@ class General extends Component
$this->dispatch('refreshStorages');
$this->dispatch('refreshEnvs');
} catch (\Throwable $e) {
$this->application->docker_compose_location = $this->initialDockerComposeLocation;
$this->application->save();
// Refresh model to get restored values from Application::loadComposeFile
$this->application->refresh();
// Sync restored values back to component properties for UI update
$this->syncData();
return handleError($e, $this);
} finally {
@@ -936,73 +937,6 @@ class General extends Component
]);
}
private function updateServiceEnvironmentVariables()
{
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
foreach ($domains as $serviceName => $service) {
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
$domain = data_get($service, 'domain');
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
->delete();
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
->delete();
if ($domain) {
// Create or update SERVICE_FQDN_ and SERVICE_URL_ variables
$fqdn = Url::fromString($domain);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$urlValue = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($path !== '/') {
$urlValue = $urlValue.$path;
}
$fqdnValue = str($domain)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
// Create/update SERVICE_FQDN_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
// Create/update SERVICE_URL_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
// Create/update port-specific variables if port exists
if (filled($port)) {
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
}
}
}
}
public function getDetectedPortInfoProperty(): ?array
{
$detectedPort = $this->application->detectPortFromEnvironment();
@@ -66,7 +66,7 @@ class Rollback extends Component
return;
}
return redirect()->route('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $deployment_uuid,
+7 -7
View File
@@ -8,7 +8,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class BackupEdit extends Component
{
@@ -184,13 +183,14 @@ class BackupEdit extends Component
$this->backup->delete();
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$previousUrl = url()->previous();
$url = Url::fromString($previousUrl);
$url = $url->withoutQueryParameter('selectedBackupId');
$url = $url->withFragment('backups');
$url = $url->getPath()."#{$url->getFragment()}";
$serviceDatabase = $this->backup->database;
return redirect($url);
return redirect()->route('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', $this->parameters);
}
@@ -168,21 +168,20 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -178,21 +178,20 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
+212 -49
View File
@@ -4,9 +4,11 @@ namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Import extends Component
@@ -101,11 +103,23 @@ class Import extends Component
public bool $unsupported = false;
public $resource;
// Store IDs instead of models for proper Livewire serialization
public ?int $resourceId = null;
public $parameters;
public ?string $resourceType = null;
public $containers;
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
public string $resourceUuid = '';
public string $resourceStatus = '';
public string $resourceDbType = '';
public array $parameters = [];
public array $containers = [];
public bool $scpInProgress = false;
@@ -121,8 +135,6 @@ class Import extends Component
public bool $error = false;
public Server $server;
public string $container;
public array $importCommands = [];
@@ -135,7 +147,7 @@ class Import extends Component
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:\${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
@@ -144,7 +156,7 @@ class Import extends Component
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public $availableS3Storages = [];
public array $availableS3Storages = [];
public ?int $s3StorageId = null;
@@ -152,6 +164,26 @@ class Import extends Component
public ?int $s3FileSize = null;
#[Computed]
public function resource()
{
if ($this->resourceId === null || $this->resourceType === null) {
return null;
}
return $this->resourceType::find($this->resourceId);
}
#[Computed]
public function server()
{
if ($this->serverId === null) {
return null;
}
return Server::find($this->serverId);
}
public function getListeners()
{
$userId = Auth::id();
@@ -176,47 +208,64 @@ class Import extends Component
public function updatedDumpAll($value)
{
switch ($this->resource->getMorphClass()) {
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`default\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD default
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD default';
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
} else {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`default\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD default
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD default';
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
} else {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && \
createdb -U $POSTGRES_USER postgres
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:\${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U $POSTGRES_USER postgres';
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:\${POSTGRES_USER:-postgres}}';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:\${POSTGRES_USER:-postgres}}';
}
break;
}
@@ -225,30 +274,99 @@ EOD;
public function getContainers()
{
$this->containers = collect();
if (! data_get($this->parameters, 'database_uuid')) {
abort(404);
}
$resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id'));
if (is_null($resource)) {
$this->containers = [];
$teamId = data_get(auth()->user()->currentTeam(), 'id');
// Try to find resource by route parameter
$databaseUuid = data_get($this->parameters, 'database_uuid');
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
$resource = null;
if ($databaseUuid) {
// Standalone database route
$resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
abort(404);
}
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$service = Service::whereUuid($serviceUuid)->first();
if (! $service) {
abort(404);
}
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
}
} else {
abort(404);
}
$this->authorize('view', $resource);
$this->resource = $resource;
$this->server = $this->resource->destination->server;
$this->container = $this->resource->uuid;
if (str(data_get($this, 'resource.status'))->startsWith('running')) {
$this->containers->push($this->container);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id;
$this->resourceType = get_class($resource);
// Store view-friendly properties
$this->resourceStatus = $resource->status ?? '';
// Handle ServiceDatabase server access differently
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$server = $resource->service?->server;
if (! $server) {
abort(404, 'Server not found for this service database.');
}
$this->serverId = $server->id;
$this->container = $resource->name.'-'.$resource->service->uuid;
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
// Determine database type for ServiceDatabase
$dbType = $resource->databaseType();
if (str_contains($dbType, 'postgres')) {
$this->resourceDbType = 'standalone-postgresql';
} elseif (str_contains($dbType, 'mysql')) {
$this->resourceDbType = 'standalone-mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$this->resourceDbType = 'standalone-mariadb';
} elseif (str_contains($dbType, 'mongo')) {
$this->resourceDbType = 'standalone-mongodb';
} else {
$this->resourceDbType = $dbType;
}
} else {
$server = $resource->destination?->server;
if (! $server) {
abort(404, 'Server not found for this database.');
}
$this->serverId = $server->id;
$this->container = $resource->uuid;
$this->resourceUuid = $resource->uuid;
$this->resourceDbType = $resource->type();
}
if (str($resource->status)->startsWith('running')) {
$this->containers[] = $this->container;
}
if (
$this->resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
$resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
$resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
$resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
$resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
) {
$this->unsupported = true;
}
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true;
}
}
}
public function checkFile()
@@ -261,6 +379,12 @@ EOD;
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
@@ -286,15 +410,22 @@ EOD;
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resource->uuid}/restore";
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
@@ -305,7 +436,7 @@ EOD;
return;
}
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
@@ -315,7 +446,7 @@ EOD;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
@@ -355,9 +486,11 @@ EOD;
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get();
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) {
$this->availableS3Storages = collect();
$this->availableS3Storages = [];
}
}
@@ -460,6 +593,12 @@ EOD;
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$this->importRunning = true;
@@ -493,14 +632,18 @@ EOD;
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resource->uuid}";
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
// Prepare all commands in sequence
$commands = [];
@@ -575,32 +718,52 @@ EOD;
public function buildRestoreCommand(string $tmpPath): string
{
switch ($this->resource->getMorphClass()) {
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === \App\Models\ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:\${POSTGRES_USER:-postgres}}";
} else {
$restoreCommand .= " {$tmpPath}";
}
break;
case \App\Models\StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
@@ -185,21 +185,20 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -91,8 +91,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mariadbRootPassword.required' => 'The Root Password field is required.',
'mariadbUser.required' => 'The MariaDB User field is required.',
'mariadbPassword.required' => 'The MariaDB Password field is required.',
@@ -234,22 +232,23 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@@ -91,8 +91,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
@@ -237,22 +235,23 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@@ -94,8 +94,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mysqlRootPassword.required' => 'The Root Password field is required.',
'mysqlUser.required' => 'The MySQL User field is required.',
'mysqlPassword.required' => 'The MySQL Password field is required.',
@@ -241,22 +239,23 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@@ -106,8 +106,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'postgresUser.required' => 'The Postgres User field is required.',
'postgresPassword.required' => 'The Postgres Password field is required.',
'postgresDb.required' => 'The Postgres Database field is required.',
@@ -288,22 +286,23 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@@ -88,8 +88,6 @@ class General extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'redisUsername.required' => 'The Redis Username field is required.',
@@ -227,21 +225,20 @@ class General extends Component
return;
}
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
if ($this->isPublic && ! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
return;
}
return;
}
$this->syncData(true);
if ($this->isPublic) {
StartDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
+1 -1
View File
@@ -39,7 +39,7 @@ class DeleteEnvironment extends Component
if ($environment->isEmpty()) {
$environment->delete();
return redirect()->route('project.show', parameters: ['project_uuid' => $this->parameters['project_uuid']]);
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
}
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");
+1 -1
View File
@@ -35,7 +35,7 @@ class DeleteProject extends Component
if ($project->isEmpty()) {
$project->delete();
return redirect()->route('project.index');
return redirectRoute($this, 'project.index');
}
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");
+1 -1
View File
@@ -63,7 +63,7 @@ class EnvironmentEdit extends Component
{
try {
$this->syncData(true);
$this->redirectRoute('project.environment.edit', [
redirectRoute($this, 'project.environment.edit', [
'environment_uuid' => $this->environment->uuid,
'project_uuid' => $this->project->uuid,
]);
+1 -1
View File
@@ -154,7 +154,7 @@ class DockerImage extends Component
'fqdn' => $fqdn,
]);
return redirect()->route('project.application.configuration', [
return redirectRoute($this, 'project.application.configuration', [
'application_uuid' => $application->uuid,
'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid,
+1 -1
View File
@@ -16,6 +16,6 @@ class EmptyProject extends Component
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
}
}
-228
View File
@@ -1,228 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Database extends Component
{
use AuthorizesRequests;
public ServiceDatabase $database;
public ?string $db_url_public = null;
public $fileStorages;
public $parameters;
public ?string $humanName = null;
public ?string $description = null;
public ?string $image = null;
public bool $excludeFromStatus = false;
public ?int $publicPort = null;
public bool $isPublic = false;
public bool $isLogDrainEnabled = false;
protected $listeners = ['refreshFileStorages'];
protected $rules = [
'humanName' => 'nullable',
'description' => 'nullable',
'image' => 'required',
'excludeFromStatus' => 'required|boolean',
'publicPort' => 'nullable|integer',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
];
public function render()
{
return view('livewire.project.service.database');
}
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->database);
if ($this->database->is_public) {
$this->db_url_public = $this->database->getServiceDatabaseUrl();
}
$this->refreshFileStorages();
$this->syncData(false);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->database->human_name = $this->humanName;
$this->database->description = $this->description;
$this->database->image = $this->image;
$this->database->exclude_from_status = $this->excludeFromStatus;
$this->database->public_port = $this->publicPort;
$this->database->is_public = $this->isPublic;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
} else {
$this->humanName = $this->database->human_name;
$this->description = $this->database->description;
$this->image = $this->database->image;
$this->excludeFromStatus = $this->database->exclude_from_status ?? false;
$this->publicPort = $this->database->public_port;
$this->isPublic = $this->database->is_public ?? false;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled ?? false;
}
}
public function delete($password)
{
try {
$this->authorize('delete', $this->database);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->database->delete();
$this->dispatch('success', 'Database deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveExclude()
{
try {
$this->authorize('update', $this->database);
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveLogDrain()
{
try {
$this->authorize('update', $this->database);
if (! $this->database->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->submit();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function convertToApplication()
{
try {
$this->authorize('update', $this->database);
$service = $this->database->service;
$serviceDatabase = $this->database;
// Check if application with same name already exists
if ($service->applications()->where('name', $serviceDatabase->name)->exists()) {
throw new \Exception('An application with this name already exists.');
}
// Create new parameters removing database_uuid
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceDatabase) {
$service->applications()->create([
'name' => $serviceDatabase->name,
'human_name' => $serviceDatabase->human_name,
'description' => $serviceDatabase->description,
'exclude_from_status' => $serviceDatabase->exclude_from_status,
'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled,
'image' => $serviceDatabase->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceDatabase->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->database);
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
return;
}
$this->syncData(true);
if ($this->database->is_public) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
$this->database->is_public = false;
return;
}
StartDatabaseProxy::run($this->database);
$this->db_url_public = $this->database->getServiceDatabaseUrl();
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->database);
$this->db_url_public = null;
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function refreshFileStorages()
{
$this->fileStorages = $this->database->fileStorages()->get();
}
public function submit()
{
try {
$this->authorize('update', $this->database);
$this->validate();
$this->syncData(true);
$this->database->save();
$this->database->refresh();
$this->syncData(false);
updateCompose($this->database);
$this->dispatch('success', 'Database saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('generateDockerCompose');
}
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class DatabaseBackups extends Component
{
use AuthorizesRequests;
public ?Service $service = null;
public ?ServiceDatabase $serviceDatabase = null;
public array $parameters;
public array $query;
public bool $isImportSupported = false;
protected $listeners = ['refreshScheduledBackups' => '$refresh'];
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
}
$this->authorize('view', $this->service);
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
if (! $this->serviceDatabase) {
return redirect()->route('project.service.configuration', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
]);
}
// Check if backups are supported for this database
if (! $this->serviceDatabase->isBackupSolutionAvailable() && ! $this->serviceDatabase->is_migrated) {
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));
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.service.database-backups');
}
}
+487 -1
View File
@@ -2,12 +2,17 @@
namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component
{
@@ -19,6 +24,10 @@ class Index extends Component
public ?ServiceDatabase $serviceDatabase = null;
public ?string $resourceType = null;
public ?string $currentRoute = null;
public array $parameters;
public array $query;
@@ -27,7 +36,67 @@ class Index extends Component
public $s3s;
protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh'];
public ?Server $server = null;
// Database-specific properties
public ?string $db_url_public = null;
public $fileStorages;
public ?string $humanName = null;
public ?string $description = null;
public ?string $image = null;
public bool $excludeFromStatus = false;
public ?int $publicPort = null;
public bool $isPublic = false;
public bool $isLogDrainEnabled = false;
public bool $isImportSupported = false;
// Application-specific properties
public $docker_cleanup = true;
public $delete_volumes = true;
public $domainConflicts = [];
public $showDomainConflictModal = false;
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
public ?string $fqdn = null;
public bool $isGzipEnabled = false;
public bool $isStripprefixEnabled = false;
protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages'];
protected $rules = [
'humanName' => 'nullable',
'description' => 'nullable',
'image' => 'required',
'excludeFromStatus' => 'required|boolean',
'publicPort' => 'nullable|integer',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
// Application-specific rules
'fqdn' => 'nullable',
'isGzipEnabled' => 'nullable|boolean',
'isStripprefixEnabled' => 'nullable|boolean',
];
public function mount()
{
@@ -35,6 +104,7 @@ class Index extends Component
$this->services = collect([]);
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
$this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
if (! $this->service) {
return redirect()->route('dashboard');
@@ -43,10 +113,21 @@ class Index extends Component
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {
$this->serviceApplication = $service;
$this->resourceType = 'application';
$this->serviceApplication->getFilesFromServer();
$this->initializeApplicationProperties();
} else {
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
if (! $this->serviceDatabase) {
return redirect()->route('project.service.configuration', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
]);
}
$this->resourceType = 'database';
$this->serviceDatabase->getFilesFromServer();
$this->initializeDatabaseProperties();
}
$this->s3s = currentTeam()->s3s;
} catch (\Throwable $e) {
@@ -54,6 +135,42 @@ class Index extends Component
}
}
private function initializeDatabaseProperties(): void
{
$this->server = $this->serviceDatabase->service->destination->server;
if ($this->serviceDatabase->is_public) {
$this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl();
}
$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
{
if ($toModel) {
$this->serviceDatabase->human_name = $this->humanName;
$this->serviceDatabase->description = $this->description;
$this->serviceDatabase->image = $this->image;
$this->serviceDatabase->exclude_from_status = $this->excludeFromStatus;
$this->serviceDatabase->public_port = $this->publicPort;
$this->serviceDatabase->is_public = $this->isPublic;
$this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled;
} else {
$this->humanName = $this->serviceDatabase->human_name;
$this->description = $this->serviceDatabase->description;
$this->image = $this->serviceDatabase->image;
$this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false;
$this->publicPort = $this->serviceDatabase->public_port;
$this->isPublic = $this->serviceDatabase->is_public ?? false;
$this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false;
}
}
public function generateDockerCompose()
{
try {
@@ -64,6 +181,375 @@ class Index extends Component
}
}
// Database-specific methods
public function refreshFileStorages()
{
if ($this->serviceDatabase) {
$this->fileStorages = $this->serviceDatabase->fileStorages()->get();
}
}
public function deleteDatabase($password)
{
try {
$this->authorize('delete', $this->serviceDatabase);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->serviceDatabase->delete();
$this->dispatch('success', 'Database deleted.');
return redirectRoute($this, 'project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveExclude()
{
try {
$this->authorize('update', $this->serviceDatabase);
$this->submitDatabase();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveLogDrain()
{
try {
$this->authorize('update', $this->serviceDatabase);
if (! $this->serviceDatabase->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->submitDatabase();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function convertToApplication()
{
try {
$this->authorize('update', $this->serviceDatabase);
$service = $this->serviceDatabase->service;
$serviceDatabase = $this->serviceDatabase;
// Check if application with same name already exists
if ($service->applications()->where('name', $serviceDatabase->name)->exists()) {
throw new \Exception('An application with this name already exists.');
}
// Create new parameters removing database_uuid
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceDatabase) {
$service->applications()->create([
'name' => $serviceDatabase->name,
'human_name' => $serviceDatabase->human_name,
'description' => $serviceDatabase->description,
'exclude_from_status' => $serviceDatabase->exclude_from_status,
'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled,
'image' => $serviceDatabase->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceDatabase->delete();
});
return redirectRoute($this, 'project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->serviceDatabase);
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
return;
}
$this->syncDatabaseData(true);
if ($this->serviceDatabase->is_public) {
if (! str($this->serviceDatabase->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
$this->serviceDatabase->is_public = false;
return;
}
StartDatabaseProxy::run($this->serviceDatabase);
$this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl();
$this->dispatch('success', 'Database is now publicly accessible.');
} else {
StopDatabaseProxy::run($this->serviceDatabase);
$this->db_url_public = null;
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitDatabase()
{
try {
$this->authorize('update', $this->serviceDatabase);
$this->validate();
$this->syncDatabaseData(true);
$this->serviceDatabase->save();
$this->serviceDatabase->refresh();
$this->syncDatabaseData(false);
updateCompose($this->serviceDatabase);
$this->dispatch('success', 'Database saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('generateDockerCompose');
}
}
// Application-specific methods
private function initializeApplicationProperties(): void
{
$this->requiredPort = $this->serviceApplication->getRequiredPort();
$this->syncApplicationData(false);
}
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->image = $this->image;
$this->serviceApplication->exclude_from_status = $this->excludeFromStatus;
$this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled;
$this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled;
} else {
$this->humanName = $this->serviceApplication->human_name;
$this->description = $this->serviceApplication->description;
$this->fqdn = $this->serviceApplication->fqdn;
$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);
$this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true);
$this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true);
}
}
public function instantSaveApplication()
{
try {
$this->authorize('update', $this->serviceApplication);
$this->submitApplication();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveApplicationSettings()
{
try {
$this->authorize('update', $this->serviceApplication);
$this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled;
$this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->serviceApplication->exclude_from_status = $this->excludeFromStatus;
$this->serviceApplication->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveApplicationAdvanced()
{
try {
$this->authorize('update', $this->serviceApplication);
if (! $this->serviceApplication->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->syncApplicationData(true);
$this->serviceApplication->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deleteApplication($password)
{
try {
$this->authorize('delete', $this->serviceApplication);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->serviceApplication->delete();
$this->dispatch('success', 'Application deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function convertToDatabase()
{
try {
$this->authorize('update', $this->serviceApplication);
$service = $this->serviceApplication->service;
$serviceApplication = $this->serviceApplication;
if ($service->databases()->where('name', $serviceApplication->name)->exists()) {
throw new \Exception('A database with this name already exists.');
}
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceApplication) {
$service->databases()->create([
'name' => $serviceApplication->name,
'human_name' => $serviceApplication->human_name,
'description' => $serviceApplication->description,
'exclude_from_status' => $serviceApplication->exclude_from_status,
'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled,
'image' => $serviceApplication->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceApplication->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function confirmDomainUsage()
{
$this->forceSaveDomains = true;
$this->showDomainConflictModal = false;
$this->submitApplication();
}
public function confirmRemovePort()
{
$this->forceRemovePort = true;
$this->showPortWarningModal = false;
$this->submitApplication();
}
public function cancelRemovePort()
{
$this->showPortWarningModal = false;
$this->syncApplicationData(false);
}
public function submitApplication()
{
try {
$this->authorize('update', $this->serviceApplication);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
$this->syncApplicationData(true);
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->serviceApplication);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
return;
}
} else {
$this->forceSaveDomains = false;
}
if (! $this->forceRemovePort) {
$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)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
$this->forceRemovePort = false;
}
$this->validate();
$this->serviceApplication->save();
$this->serviceApplication->refresh();
$this->syncApplicationData(false);
updateCompose($this->serviceApplication);
if (str($this->serviceApplication->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
} else {
! $warning && $this->dispatch('success', 'Service saved.');
}
$this->dispatch('generateDockerCompose');
} catch (\Throwable $e) {
$originalFqdn = $this->serviceApplication->getOriginal('fqdn');
if ($originalFqdn !== $this->serviceApplication->fqdn) {
$this->serviceApplication->fqdn = $originalFqdn;
$this->syncApplicationData(false);
}
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.service.index');
@@ -1,345 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class ServiceApplicationView extends Component
{
use AuthorizesRequests;
public ServiceApplication $application;
public $parameters;
public $docker_cleanup = true;
public $delete_volumes = true;
public $domainConflicts = [];
public $showDomainConflictModal = false;
public $forceSaveDomains = false;
public $showPortWarningModal = false;
public $forceRemovePort = false;
public $requiredPort = null;
#[Validate(['nullable'])]
public ?string $humanName = null;
#[Validate(['nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
#[Validate(['string', 'nullable'])]
public ?string $image = null;
#[Validate(['required', 'boolean'])]
public bool $excludeFromStatus = false;
#[Validate(['nullable', 'boolean'])]
public bool $isLogDrainEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isGzipEnabled = false;
#[Validate(['nullable', 'boolean'])]
public bool $isStripprefixEnabled = false;
protected $rules = [
'humanName' => 'nullable',
'description' => 'nullable',
'fqdn' => 'nullable',
'image' => 'string|nullable',
'excludeFromStatus' => 'required|boolean',
'application.required_fqdn' => 'required|boolean',
'isLogDrainEnabled' => 'nullable|boolean',
'isGzipEnabled' => 'nullable|boolean',
'isStripprefixEnabled' => 'nullable|boolean',
];
public function instantSave()
{
try {
$this->authorize('update', $this->application);
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveSettings()
{
try {
$this->authorize('update', $this->application);
// Save checkbox states without port validation
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveAdvanced()
{
try {
$this->authorize('update', $this->application);
if (! $this->application->service->destination->server->isLogDrainEnabled()) {
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
// Sync component properties to model
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function delete($password)
{
try {
$this->authorize('delete', $this->application);
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->application->delete();
$this->dispatch('success', 'Application deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
$this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function confirmRemovePort()
{
$this->forceRemovePort = true;
$this->showPortWarningModal = false;
$this->submit();
}
public function cancelRemovePort()
{
$this->showPortWarningModal = false;
$this->syncData(); // Reset to original FQDN
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync to model
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
} else {
// Sync from model
$this->humanName = $this->application->human_name;
$this->description = $this->application->description;
$this->fqdn = $this->application->fqdn;
$this->image = $this->application->image;
$this->excludeFromStatus = data_get($this->application, 'exclude_from_status', false);
$this->isLogDrainEnabled = data_get($this->application, 'is_log_drain_enabled', false);
$this->isGzipEnabled = data_get($this->application, 'is_gzip_enabled', true);
$this->isStripprefixEnabled = data_get($this->application, 'is_stripprefix_enabled', true);
}
}
public function convertToDatabase()
{
try {
$this->authorize('update', $this->application);
$service = $this->application->service;
$serviceApplication = $this->application;
// Check if database with same name already exists
if ($service->databases()->where('name', $serviceApplication->name)->exists()) {
throw new \Exception('A database with this name already exists.');
}
$redirectParams = collect($this->parameters)
->except('database_uuid')
->all();
DB::transaction(function () use ($service, $serviceApplication) {
$service->databases()->create([
'name' => $serviceApplication->name,
'human_name' => $serviceApplication->human_name,
'description' => $serviceApplication->description,
'exclude_from_status' => $serviceApplication->exclude_from_status,
'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled,
'image' => $serviceApplication->image,
'service_id' => $service->id,
'is_migrated' => true,
]);
$serviceApplication->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function confirmDomainUsage()
{
$this->forceSaveDomains = true;
$this->showDomainConflictModal = false;
$this->submit();
}
public function submit()
{
try {
$this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
// Sync to model for domain conflict check (without validation)
$this->application->human_name = $this->humanName;
$this->application->description = $this->description;
$this->application->fqdn = $this->fqdn;
$this->application->image = $this->image;
$this->application->exclude_from_status = $this->excludeFromStatus;
$this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->is_gzip_enabled = $this->isGzipEnabled;
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
return;
}
} else {
// Reset the force flag after using it
$this->forceSaveDomains = false;
}
// Check for required port
if (! $this->forceRemovePort) {
$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)) {
continue;
}
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
// Reset the force flag after using it
$this->forceRemovePort = false;
}
$this->validate();
$this->application->save();
$this->application->refresh();
$this->syncData();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
} else {
! $warning && $this->dispatch('success', 'Service saved.');
}
$this->dispatch('generateDockerCompose');
} catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncData();
}
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.project.service.service-application-view', [
'checkboxes' => [
['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')],
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
// ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'],
// ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'],
// ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.']
],
]);
}
}
@@ -52,8 +52,6 @@ class StackForm extends Component
ValidationPatterns::combinedMessages(),
[
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'dockerComposeRaw.required' => 'The Docker Compose Raw field is required.',
'dockerCompose.required' => 'The Docker Compose field is required.',
]
+1 -1
View File
@@ -111,7 +111,7 @@ class Danger extends Component
$this->docker_cleanup
);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->projectUuid,
'environment_uuid' => $this->environmentUuid,
]);
+1 -1
View File
@@ -97,7 +97,7 @@ class Destination extends Component
return;
}
return redirect()->route('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => data_get($this->resource, 'environment.project.uuid'),
'application_uuid' => data_get($this->resource, 'uuid'),
'deployment_uuid' => $deployment_uuid,
@@ -63,20 +63,30 @@ class All extends Component
public function getEnvironmentVariablesProperty()
{
if ($this->is_env_sorting_enabled === false) {
return $this->resource->environment_variables()->orderBy('order')->get();
$query = $this->resource->environment_variables()
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
} else {
$query->orderBy('order');
}
return $this->resource->environment_variables;
return $query->get();
}
public function getEnvironmentVariablesPreviewProperty()
{
if ($this->is_env_sorting_enabled === false) {
return $this->resource->environment_variables_preview()->orderBy('order')->get();
$query = $this->resource->environment_variables_preview()
->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
} else {
$query->orderBy('order');
}
return $this->resource->environment_variables_preview;
return $query->get();
}
public function getDevView()
+85 -3
View File
@@ -21,6 +21,10 @@ use Livewire\Component;
class GetLogs extends Component
{
public const MAX_LOG_LINES = 50000;
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
public string $outputs = '';
public string $errors = '';
@@ -123,6 +127,9 @@ class GetLogs extends Component
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
$this->numberOfLines = 1000;
}
if ($this->numberOfLines > self::MAX_LOG_LINES) {
$this->numberOfLines = self::MAX_LOG_LINES;
}
if ($this->container) {
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
@@ -159,10 +166,12 @@ class GetLogs extends Component
}
// Collect new logs into temporary variable first to prevent flickering
// (avoids clearing output before new data is ready)
$newOutputs = '';
Process::run($sshCommand, function (string $type, string $output) use (&$newOutputs) {
$newOutputs .= removeAnsiColors($output);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
$logChunks = [];
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
$logChunks[] = removeAnsiColors($output);
});
$newOutputs = implode('', $logChunks);
if ($this->showTimeStamps) {
$newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) {
@@ -183,6 +192,79 @@ class GetLogs extends Component
return sanitizeLogsForExport($this->outputs);
}
public function downloadAllLogs(): string
{
if (! $this->server->isFunctional() || ! $this->container) {
return '';
}
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
$command = "docker service logs -t {$this->container}";
} else {
$command = "docker logs -t {$this->container}";
}
} else {
if ($this->server->isSwarm()) {
$command = "docker service logs {$this->container}";
} else {
$command = "docker logs {$this->container}";
}
}
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
// Enforce 50MB size limit to prevent memory exhaustion from large logs
$logChunks = [];
$accumulatedBytes = 0;
$truncated = false;
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
if ($truncated) {
return;
}
$output = removeAnsiColors($output);
$outputBytes = strlen($output);
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
if ($remaining > 0) {
$logChunks[] = substr($output, 0, $remaining);
}
$truncated = true;
return;
}
$logChunks[] = $output;
$accumulatedBytes += $outputBytes;
});
$allLogs = implode('', $logChunks);
if ($truncated) {
$allLogs .= "\n\n[... Output truncated at 50MB limit ...]";
}
if ($this->showTimeStamps) {
$allLogs = str($allLogs)->split('/\n/')->sort(function ($a, $b) {
$a = explode(' ', $a);
$b = explode(' ', $b);
return $a[0] <=> $b[0];
})->join("\n");
}
return sanitizeLogsForExport($allLogs);
}
public function render()
{
return view('livewire.project.shared.get-logs');
+8 -1
View File
@@ -57,7 +57,14 @@ class Terminal extends Component
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'");
// Add sudo for non-root users to access Docker socket
$dockerCommand = "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'";
if ($server->isNonRoot()) {
$dockerCommand = "sudo {$dockerCommand}";
}
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
+2 -2
View File
@@ -48,7 +48,7 @@ class Show extends Component
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
]);
@@ -59,7 +59,7 @@ class Show extends Component
public function navigateToEnvironment($projectUuid, $environmentUuid)
{
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $projectUuid,
'environment_uuid' => $environmentUuid,
]);