diff --git a/.env.testing b/.env.testing
index 1a73117986..d445b5afed 100644
--- a/.env.testing
+++ b/.env.testing
@@ -1,6 +1,7 @@
APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
APP_DEBUG=true
+APP_MAINTENANCE_DRIVER=file
DB_CONNECTION=testing
diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index b256eb2255..f9e92e08f1 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -3,12 +3,14 @@
namespace App\Actions\Database;
use App\Models\StandaloneClickhouse;
+use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
+use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartClickhouse
{
- use AsAction;
+ use AsAction, ExecutesDatabaseStartCommands;
public StandaloneClickhouse $database;
@@ -16,7 +18,11 @@ class StartClickhouse
public string $configuration_dir;
- public function handle(StandaloneClickhouse $database)
+ private string $resolvedClickhouseUser;
+
+ private string $resolvedClickhousePassword;
+
+ public function handle(StandaloneClickhouse $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -51,7 +57,7 @@ class StartClickhouse
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
- 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
+ 'CMD', 'clickhouse-client', '--user', $this->resolvedClickhouseUser, '--password', $this->resolvedClickhousePassword, '--query', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -109,7 +115,7 @@ class StartClickhouse
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
- return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+ return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -147,8 +153,17 @@ class StartClickhouse
private function generate_environment_variables()
{
$environment_variables = collect();
+ $this->resolvedClickhouseUser = (string) $this->database->clickhouse_admin_user;
+ $this->resolvedClickhousePassword = (string) $this->database->clickhouse_admin_password;
foreach ($this->database->runtime_environment_variables as $env) {
- $environment_variables->push("$env->key=$env->real_value");
+ $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+ $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
+ $environment_variables->push($env->key.'='.$resolvedValue);
+ if ($env->key === 'CLICKHOUSE_USER') {
+ $this->resolvedClickhouseUser = $rawValue;
+ } elseif ($env->key === 'CLICKHOUSE_PASSWORD') {
+ $this->resolvedClickhousePassword = $rawValue;
+ }
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php
index cd7e083286..cb1c517539 100644
--- a/app/Actions/Database/StartDatabase.php
+++ b/app/Actions/Database/StartDatabase.php
@@ -2,6 +2,9 @@
namespace App\Actions\Database;
+use App\Enums\ActivityTypes;
+use App\Enums\ProcessStatus;
+use App\Jobs\DatabaseStartJob;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@@ -12,6 +15,7 @@ use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
+use Spatie\Activitylog\Models\Activity;
class StartDatabase
{
@@ -22,7 +26,7 @@ class StartDatabase
$job->onQueue(deployment_queue());
}
- public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
+ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): Activity|string
{
$server = $database->destination->server;
if (! $server->isFunctional()) {
@@ -33,32 +37,32 @@ class StartDatabase
'last_restart_at' => null,
'last_restart_type' => null,
]);
- switch ($database->getMorphClass()) {
- case StandalonePostgresql::class:
- $activity = StartPostgresql::run($database);
- break;
- case StandaloneRedis::class:
- $activity = StartRedis::run($database);
- break;
- case StandaloneMongodb::class:
- $activity = StartMongodb::run($database);
- break;
- case StandaloneMysql::class:
- $activity = StartMysql::run($database);
- break;
- case StandaloneMariadb::class:
- $activity = StartMariadb::run($database);
- break;
- case StandaloneKeydb::class:
- $activity = StartKeydb::run($database);
- break;
- case StandaloneDragonfly::class:
- $activity = StartDragonfly::run($database);
- break;
- case StandaloneClickhouse::class:
- $activity = StartClickhouse::run($database);
- break;
+
+ $activity = activity()
+ ->withProperties([
+ 'server_uuid' => $server->uuid,
+ 'type' => ActivityTypes::INLINE->value,
+ 'type_uuid' => $database->uuid,
+ 'status' => ProcessStatus::QUEUED->value,
+ 'team_id' => $server->team_id,
+ 'operation' => 'database-start',
+ ])
+ ->performedOn($database)
+ ->event(ActivityTypes::INLINE->value)
+ ->log('[]');
+
+ if ($activity === null) {
+ return 'Database start could not be queued because activity logging is disabled.';
}
+
+ DatabaseStartJob::dispatch(
+ $database->getMorphClass(),
+ (int) $database->getKey(),
+ (int) $database->team()->id,
+ (int) $activity->getKey(),
+ auth()->id(),
+ );
+
if ($database->is_public && $database->public_port) {
StartDatabaseProxy::dispatch($database);
}
diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php
new file mode 100644
index 0000000000..4b59fe7911
--- /dev/null
+++ b/app/Actions/Database/StartDatabaseImport.php
@@ -0,0 +1,199 @@
+commands->supports($resource)) {
+ throw new DatabaseImportException('Database imports are not supported for this database type.');
+ }
+ if (! str($resource->status)->startsWith('running')) {
+ throw new DatabaseImportException('The database must be running before an import can start.');
+ }
+
+ [$server, $container, $network] = $this->target($resource);
+ $destination = $resource instanceof ServiceDatabase ? $resource->service?->destination : $resource->destination;
+ if ($destination instanceof SwarmDocker) {
+ throw new DatabaseImportException('Database imports are not supported for Swarm servers yet.', 501);
+ }
+ if (! $server || ! ValidationPatterns::isValidContainerName($container)) {
+ throw new DatabaseImportException('The database server or container is invalid.', 400);
+ }
+
+ $lock = Cache::lock(self::lockKey($resource->uuid), self::LOCK_SECONDS);
+
+ if (! $lock->get()) {
+ throw new DatabaseImportException('A database import is already running.', 409);
+ }
+
+ try {
+ return $this->startImport($resource, $source, $teamId, $server, $container, $network);
+ } finally {
+ $lock->release();
+ }
+ }
+
+ private function startImport(Model $resource, DatabaseImportSource $source, int $teamId, Server $server, string $container, string $network): Activity
+ {
+ $active = Activity::query()->where('properties->team_id', $teamId)
+ ->where('properties->type_uuid', $resource->uuid)
+ ->where('properties->operation', 'database_import')
+ ->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value])
+ ->exists();
+ if ($active) {
+ throw new DatabaseImportException('A database import is already running.', 409);
+ }
+
+ $operation = (string) Str::uuid();
+ $containerPath = "/tmp/restore_{$operation}";
+ $scriptPath = "/tmp/restore_{$operation}.sh";
+ $commandList = [];
+ $cleanup = ['container' => $container, 'containerTmpPath' => $containerPath, 'scriptPath' => $scriptPath, 'serverId' => $server->id];
+
+ if ($source->type === 'upload') {
+ $staged = $source->uploadId
+ ? "upload/imports/{$teamId}/{$resource->uuid}/{$source->uploadId}/restore"
+ : "upload/{$resource->uuid}/restore";
+ if (! Storage::exists($staged)) {
+ throw new DatabaseImportException('The completed upload was not found.');
+ }
+ $local = Storage::path($staged);
+ if ($this->commands->databaseType($resource) === 'postgresql' && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($local)) {
+ Storage::delete($staged);
+ throw new DatabaseImportException('The uploaded backup contains disallowed PostgreSQL restore directives.');
+ }
+ $serverPath = "/tmp/database-import-{$operation}";
+ instant_scp($local, $serverPath, $server);
+ $source->uploadId ? Storage::deleteDirectory(dirname($staged)) : Storage::delete($staged);
+ $commandList[] = 'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}");
+ $commandList[] = 'rm -f '.escapeshellarg($serverPath);
+ $cleanup['serverTmpPath'] = $serverPath;
+ } elseif ($source->type === 'server') {
+ $this->assertServerPath($source->path);
+ $size = (int) trim((string) instant_remote_process(['stat -c %s -- '.escapeshellarg($source->path)], $server));
+ if ($size < 1 || $size > self::MAX_BYTES) {
+ throw new DatabaseImportException('The backup file is empty or exceeds the 10 GiB limit.');
+ }
+ $commandList[] = 'docker cp '.escapeshellarg($source->path).' '.escapeshellarg("{$container}:{$containerPath}");
+ } else {
+ $storage = S3Storage::ownedByCurrentTeamAPI($teamId)
+ ->where(fn ($query) => $query->whereUuid($source->s3StorageUuid)->orWhere('id', ctype_digit((string) $source->s3StorageUuid) ? (int) $source->s3StorageUuid : -1))
+ ->where('is_usable', true)->first();
+ if (! $storage || ! ValidationPatterns::isValidS3BucketName($storage->bucket)) {
+ throw new DatabaseImportException('S3 storage was not found or has an invalid bucket.');
+ }
+ $key = ltrim((string) $source->path, '/');
+ $this->assertS3Path($key);
+ $disk = $storage->filesystem();
+ if (! $disk->exists($key) || $disk->size($key) > self::MAX_BYTES) {
+ throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.');
+ }
+ $helper = "s3-restore-{$operation}";
+ $serverPath = "/tmp/s3-restore-{$operation}";
+ $this->startS3HelperWithEnv($storage, $server, $helper, $network);
+ $sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}");
+ $commandList = [
+ 'docker exec '.escapeshellarg($helper).' sh -c '.escapeshellarg('mc alias set s3temp "$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY"'),
+ 'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore',
+ 'docker cp '.escapeshellarg("{$helper}:/tmp/restore").' '.escapeshellarg($serverPath),
+ 'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}"),
+ 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
+ 'rm -f '.escapeshellarg($serverPath),
+ ];
+ $cleanup += ['containerName' => $helper, 'serverTmpPath' => $serverPath];
+ }
+
+ if ($safety = $this->commands->buildPostgresSafetyCommand($resource, $container, $containerPath)) {
+ $commandList[] = $safety;
+ }
+ $restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll, $source->replaceExisting));
+ $commandList[] = 'echo '.escapeshellarg($restore).' | base64 -d > '.escapeshellarg($scriptPath);
+ $commandList[] = 'chmod +x '.escapeshellarg($scriptPath);
+ $commandList[] = 'docker cp '.escapeshellarg($scriptPath).' '.escapeshellarg("{$container}:{$scriptPath}");
+ $commandList[] = 'rm -f '.escapeshellarg($scriptPath);
+ $commandList[] = 'docker exec '.escapeshellarg($container).' sh -c '.escapeshellarg($scriptPath);
+
+ $activity = remote_process($commandList, $server, type_uuid: $resource->uuid, model: $resource, callEventOnFinish: 'DatabaseImportFinished', callEventData: $cleanup);
+ $activity->properties = $activity->properties->merge(['operation' => 'database_import', 'resource_kind' => $resource instanceof ServiceDatabase ? 'service_database' : 'standalone_database', 'operation_uuid' => $operation]);
+ $activity->save();
+
+ return $activity;
+ }
+
+ private function target(Model $resource): array
+ {
+ if ($resource instanceof ServiceDatabase) {
+ return [$resource->service?->server, $resource->name.'-'.$resource->service?->uuid, $resource->service?->destination?->network ?? 'coolify'];
+ }
+
+ return [$resource->destination?->server, $resource->uuid, $resource->destination?->network ?? 'coolify'];
+ }
+
+ private function assertServerPath(?string $path): void
+ {
+ if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
+ throw new DatabaseImportException('The server path is invalid.');
+ }
+ }
+
+ private function assertS3Path(string $path): void
+ {
+ if ($path === '' || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
+ throw new DatabaseImportException('The S3 path is invalid.');
+ }
+ }
+
+ private function startS3HelperWithEnv(S3Storage $storage, Server $server, string $helper, string $network): void
+ {
+ $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion());
+
+ try {
+ instant_remote_process([
+ 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
+ 'docker run -d --network '.escapeshellarg($network)
+ .' --name '.escapeshellarg($helper)
+ .' -e S3_ENDPOINT='.escapeshellarg((string) $storage->endpoint)
+ .' -e S3_ACCESS_KEY='.escapeshellarg((string) $storage->key)
+ .' -e S3_SECRET_KEY='.escapeshellarg((string) $storage->secret)
+ .' '.$image.' sleep 3600',
+ ], $server);
+ } catch (Throwable) {
+ instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false);
+
+ throw new DatabaseImportException('Unable to start the S3 restore helper.');
+ }
+ }
+}
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index ddd930f278..078d557f57 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
+use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
+use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartDragonfly
{
- use AsAction;
+ use AsAction, ExecutesDatabaseStartCommands;
public StandaloneDragonfly $database;
@@ -20,7 +22,9 @@ class StartDragonfly
private ?SslCertificate $ssl_certificate = null;
- public function handle(StandaloneDragonfly $database)
+ private string $resolvedRedisPassword;
+
+ public function handle(StandaloneDragonfly $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -107,7 +111,7 @@ class StartDragonfly
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
- 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
+ 'CMD', 'redis-cli', '-a', $this->resolvedRedisPassword, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -196,12 +200,13 @@ class StartDragonfly
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
- return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+ return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function buildStartCommand(): string
{
- $command = "dragonfly --requirepass {$this->database->dragonfly_password}";
+ $escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
+ $command = "dragonfly --requirepass {$escapedRedisPassword}";
if ($this->database->enable_ssl) {
$sslArgs = [
@@ -251,8 +256,14 @@ class StartDragonfly
private function generate_environment_variables()
{
$environment_variables = collect();
+ $this->resolvedRedisPassword = (string) $this->database->dragonfly_password;
foreach ($this->database->runtime_environment_variables as $env) {
- $environment_variables->push("$env->key=$env->real_value");
+ $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+ $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
+ $environment_variables->push($env->key.'='.$resolvedValue);
+ if ($env->key === 'REDIS_PASSWORD') {
+ $this->resolvedRedisPassword = $rawValue;
+ }
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index cc017e3514..3b9cba28f4 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
+use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
+use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartKeydb
{
- use AsAction;
+ use AsAction, ExecutesDatabaseStartCommands;
public StandaloneKeydb $database;
@@ -20,7 +22,9 @@ class StartKeydb
private ?SslCertificate $ssl_certificate = null;
- public function handle(StandaloneKeydb $database)
+ private string $resolvedRedisPassword;
+
+ public function handle(StandaloneKeydb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -109,7 +113,7 @@ class StartKeydb
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
- 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
+ 'CMD', 'keydb-cli', '--pass', $this->resolvedRedisPassword, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -214,7 +218,7 @@ class StartKeydb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
- return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+ return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -252,8 +256,14 @@ class StartKeydb
private function generate_environment_variables()
{
$environment_variables = collect();
+ $this->resolvedRedisPassword = (string) $this->database->keydb_password;
foreach ($this->database->runtime_environment_variables as $env) {
- $environment_variables->push("$env->key=$env->real_value");
+ $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+ $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
+ $environment_variables->push($env->key.'='.$resolvedValue);
+ if ($env->key === 'REDIS_PASSWORD') {
+ $this->resolvedRedisPassword = $rawValue;
+ }
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
@@ -280,6 +290,7 @@ class StartKeydb
{
$hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf);
$keydbConfPath = '/etc/keydb/keydb.conf';
+ $escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
if ($hasKeydbConf) {
$confContent = $this->database->keydb_conf;
@@ -288,10 +299,10 @@ class StartKeydb
if ($hasRequirePass) {
$command = "keydb-server $keydbConfPath";
} else {
- $command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}";
+ $command = "keydb-server $keydbConfPath --requirepass {$escapedRedisPassword}";
}
} else {
- $command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
+ $command = "keydb-server --requirepass {$escapedRedisPassword} --appendonly yes";
}
if ($this->database->enable_ssl) {
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 2f030ae299..a05da25efd 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb;
+use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
+use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMariadb
{
- use AsAction;
+ use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMariadb $database;
@@ -20,7 +22,7 @@ class StartMariadb
private ?SslCertificate $ssl_certificate = null;
- public function handle(StandaloneMariadb $database)
+ public function handle(StandaloneMariadb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -216,7 +218,7 @@ class StartMariadb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
- return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+ return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -255,7 +257,7 @@ class StartMariadb
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
- $environment_variables->push("$env->key=$env->real_value");
+ $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) {
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 097e19f7b2..ff338aa99f 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb;
+use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
+use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMongodb
{
- use AsAction;
+ use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMongodb $database;
@@ -20,7 +22,13 @@ class StartMongodb
private ?SslCertificate $ssl_certificate = null;
- public function handle(StandaloneMongodb $database)
+ private string $resolvedMongoUsername;
+
+ private string $resolvedMongoPassword;
+
+ private string $resolvedMongoDatabase;
+
+ public function handle(StandaloneMongodb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -265,7 +273,7 @@ class StartMongodb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
- return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+ return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -303,8 +311,20 @@ class StartMongodb
private function generate_environment_variables()
{
$environment_variables = collect();
+ $this->resolvedMongoUsername = (string) $this->database->mongo_initdb_root_username;
+ $this->resolvedMongoPassword = (string) $this->database->mongo_initdb_root_password;
+ $this->resolvedMongoDatabase = (string) $this->database->mongo_initdb_database;
foreach ($this->database->runtime_environment_variables as $env) {
- $environment_variables->push("$env->key=$env->real_value");
+ $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+ $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
+ $environment_variables->push($env->key.'='.$resolvedValue);
+ if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') {
+ $this->resolvedMongoUsername = $rawValue;
+ } elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') {
+ $this->resolvedMongoPassword = $rawValue;
+ } elseif ($env->key === 'MONGO_INITDB_DATABASE') {
+ $this->resolvedMongoDatabase = $rawValue;
+ }
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) {
@@ -337,9 +357,9 @@ class StartMongodb
private function add_default_database()
{
- $dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
- $userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
- $pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
+ $dbJson = json_encode($this->resolvedMongoDatabase, JSON_UNESCAPED_SLASHES);
+ $userJson = json_encode($this->resolvedMongoUsername, JSON_UNESCAPED_SLASHES);
+ $pwdJson = json_encode($this->resolvedMongoPassword, JSON_UNESCAPED_SLASHES);
$content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
$content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index d21ee02fb1..cff8d0b363 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql;
+use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
+use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMysql
{
- use AsAction;
+ use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMysql $database;
@@ -20,7 +22,9 @@ class StartMysql
private ?SslCertificate $ssl_certificate = null;
- public function handle(StandaloneMysql $database)
+ private string $resolvedMysqlRootPassword;
+
+ public function handle(StandaloneMysql $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -104,7 +108,7 @@ class StartMysql
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
- 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
+ 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->resolvedMysqlRootPassword}",
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -218,7 +222,7 @@ class StartMysql
$this->commands[] = "echo 'Database started.'";
- return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+ return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -256,8 +260,14 @@ class StartMysql
private function generate_environment_variables()
{
$environment_variables = collect();
+ $this->resolvedMysqlRootPassword = (string) $this->database->mysql_root_password;
foreach ($this->database->runtime_environment_variables as $env) {
- $environment_variables->push("$env->key=$env->real_value");
+ $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+ $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
+ $environment_variables->push($env->key.'='.$resolvedValue);
+ if ($env->key === 'MYSQL_ROOT_PASSWORD') {
+ $this->resolvedMysqlRootPassword = $rawValue;
+ }
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) {
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index f70e8f3cfd..f9dd7a3c4f 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql;
+use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
+use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartPostgresql
{
- use AsAction;
+ use AsAction, ExecutesDatabaseStartCommands;
public StandalonePostgresql $database;
@@ -22,7 +24,11 @@ class StartPostgresql
private ?SslCertificate $ssl_certificate = null;
- public function handle(StandalonePostgresql $database)
+ private string $resolvedPostgresUser;
+
+ private string $resolvedPostgresDatabase;
+
+ public function handle(StandalonePostgresql $database, ?Activity $activity = null)
{
$this->database = $database;
$container_name = $this->database->uuid;
@@ -111,7 +117,7 @@ class StartPostgresql
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
- 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
+ 'CMD', 'psql', '-U', $this->resolvedPostgresUser, '-d', $this->resolvedPostgresDatabase, '-c', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -227,7 +233,7 @@ class StartPostgresql
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
- return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+ return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -265,8 +271,17 @@ class StartPostgresql
private function generate_environment_variables()
{
$environment_variables = collect();
+ $this->resolvedPostgresUser = (string) $this->database->postgres_user;
+ $this->resolvedPostgresDatabase = (string) $this->database->postgres_db;
foreach ($this->database->runtime_environment_variables as $env) {
- $environment_variables->push("$env->key=$env->real_value");
+ $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+ $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
+ $environment_variables->push($env->key.'='.$resolvedValue);
+ if ($env->key === 'POSTGRES_USER') {
+ $this->resolvedPostgresUser = $rawValue;
+ } elseif ($env->key === 'POSTGRES_DB') {
+ $this->resolvedPostgresDatabase = $rawValue;
+ }
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) {
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index 8d65453f70..41ece532b1 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
+use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
+use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartRedis
{
- use AsAction;
+ use AsAction, ExecutesDatabaseStartCommands;
public StandaloneRedis $database;
@@ -20,7 +22,11 @@ class StartRedis
private ?SslCertificate $ssl_certificate = null;
- public function handle(StandaloneRedis $database)
+ private ?string $resolvedRedisPassword = null;
+
+ private ?string $resolvedRedisUsername = null;
+
+ public function handle(StandaloneRedis $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -209,7 +215,7 @@ class StartRedis
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
- return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+ return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -249,23 +255,40 @@ class StartRedis
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
+ $usesSecretManager = $this->database->environmentVariableUsesSecretManager($env);
+
if ($env->is_shared) {
- $environment_variables->push("$env->key=$env->real_value");
+ $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
if ($env->key === 'REDIS_PASSWORD') {
- $this->database->update(['redis_password' => $env->real_value]);
+ $this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+
+ if (! $usesSecretManager) {
+ $this->database->update(['redis_password' => $this->resolvedRedisPassword]);
+ }
}
if ($env->key === 'REDIS_USERNAME') {
- $this->database->update(['redis_username' => $env->real_value]);
+ $this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+
+ if (! $usesSecretManager) {
+ $this->database->update(['redis_username' => $this->resolvedRedisUsername]);
+ }
}
} else {
- if ($env->key === 'REDIS_PASSWORD') {
+ if ($env->key === 'REDIS_PASSWORD' && ! $usesSecretManager) {
$env->update(['value' => $this->database->redis_password]);
- } elseif ($env->key === 'REDIS_USERNAME') {
+ } elseif ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) {
$env->update(['value' => $this->database->redis_username]);
}
- $environment_variables->push("$env->key=$env->real_value");
+
+ if ($env->key === 'REDIS_PASSWORD') {
+ $this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+ } elseif ($env->key === 'REDIS_USERNAME') {
+ $this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
+ }
+
+ $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
}
}
@@ -276,6 +299,7 @@ class StartRedis
private function buildStartCommand(): string
{
+ $redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password;
$hasRedisConf = ! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf);
$redisConfPath = '/usr/local/etc/redis/redis.conf';
@@ -286,10 +310,10 @@ class StartRedis
if ($hasRequirePass) {
$command = "redis-server $redisConfPath";
} else {
- $command = "redis-server $redisConfPath --requirepass {$this->database->redis_password}";
+ $command = "redis-server $redisConfPath --requirepass {$redisPassword}";
}
} else {
- $command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes";
+ $command = "redis-server --requirepass {$redisPassword} --appendonly yes";
}
if ($this->database->enable_ssl) {
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
index 44602f0619..c69863c572 100644
--- a/app/Actions/Fortify/CreateNewUser.php
+++ b/app/Actions/Fortify/CreateNewUser.php
@@ -30,7 +30,7 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
$settings = instanceSettings();
- if (! $settings->is_registration_enabled) {
+ if (! $settings->isPasswordRegistrationAllowed()) {
abort(403);
}
diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php
index f90e007089..5cf5658f8f 100644
--- a/app/Actions/Server/CheckUpdates.php
+++ b/app/Actions/Server/CheckUpdates.php
@@ -3,6 +3,7 @@
namespace App\Actions\Server;
use App\Models\Server;
+use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
class CheckUpdates
@@ -106,6 +107,15 @@ class CheckUpdates
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
+ return $out;
+ case 'apk':
+ instant_remote_process(['apk update -q'], $server);
+ $output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server);
+
+ $out = $this->parseApkOutput($output);
+ $out['osId'] = $osId;
+ $out['package_manager'] = $packageManager;
+
return $out;
default:
return [
@@ -266,11 +276,39 @@ class CheckUpdates
// Include unparsed lines in the result for debugging if any exist
if (! empty($unparsedLines)) {
$result['unparsed_lines'] = $unparsedLines;
- \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
+ Log::debug('Pacman output contained unparsed lines', [
'unparsed_lines' => $unparsedLines,
]);
}
return $result;
}
+
+ private function parseApkOutput(string $output): array
+ {
+ $updates = [];
+ $lines = explode("\n", $output);
+
+ foreach ($lines as $line) {
+ // Skip empty lines
+ if (empty($line)) {
+ continue;
+ }
+
+ // Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
+ if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) {
+ $updates[] = [
+ 'package' => $matches[1],
+ 'new_version' => $matches[2],
+ 'architecture' => $matches[3],
+ 'current_version' => $matches[4],
+ ];
+ }
+ }
+
+ return [
+ 'total_updates' => count($updates),
+ 'updates' => $updates,
+ ];
+ }
}
diff --git a/app/Actions/Server/ConfigureTrafficAnalytics.php b/app/Actions/Server/ConfigureTrafficAnalytics.php
new file mode 100644
index 0000000000..9121443c62
--- /dev/null
+++ b/app/Actions/Server/ConfigureTrafficAnalytics.php
@@ -0,0 +1,33 @@
+settings->is_sentinel_enabled;
+
+ $server->settings->is_traffic_analytics_enabled = $enable;
+ $server->settings->save();
+ $server->refresh();
+
+ // Regenerate proxy config so the (Traefik) access-log flags / (Caddy) log labels take effect.
+ GetProxyConfiguration::run($server, forceRegenerate: true);
+ RestartProxyJob::dispatch($server);
+
+ // Recreate Sentinel so it picks up (enabling) or drops (disabling) the traffic env + proxy-log mount.
+ // Enabling analytics needs Sentinel running; when disabling, only restart if Sentinel was already
+ // enabled so we never turn Sentinel on as a side effect of disabling analytics.
+ if ($enable || $sentinelWasEnabled) {
+ StartSentinel::run($server, restart: true);
+ }
+ }
+}
diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php
index 2e08ec6ad9..552445d728 100644
--- a/app/Actions/Server/InstallDocker.php
+++ b/app/Actions/Server/InstallDocker.php
@@ -79,6 +79,8 @@ class InstallDocker
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
} elseif ($supported_os_type->contains('arch')) {
$command = $command->merge([$this->getArchDockerInstallCommand()]);
+ } elseif ($supported_os_type->contains('alpine')) {
+ $command = $command->merge([$this->getAlpineDockerInstallCommand()]);
} else {
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
}
@@ -93,9 +95,8 @@ class InstallDocker
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json',
"echo 'Restarting Docker Engine...'",
- 'systemctl enable docker >/dev/null 2>&1 || true',
- 'systemctl restart docker',
]);
+ $command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine')));
if ($server->isSwarm()) {
$command = $command->merge([
'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true',
@@ -154,6 +155,28 @@ class InstallDocker
'systemctl start docker.service';
}
+ private function getAlpineDockerInstallCommand(): string
+ {
+ return 'apk update && '.
+ 'apk add docker docker-cli-buildx docker-cli-compose && '.
+ 'mkdir -p /etc/docker';
+ }
+
+ private function getDockerServiceCommands(bool $usesOpenRc): array
+ {
+ if ($usesOpenRc) {
+ return [
+ 'rc-update add docker default',
+ 'rc-service docker restart',
+ ];
+ }
+
+ return [
+ 'systemctl enable docker >/dev/null 2>&1 || true',
+ 'systemctl restart docker',
+ ];
+ }
+
private function getGenericDockerInstallCommand(): string
{
return 'curl -fsSL https://get.docker.com | sh';
diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php
index 84be7f2068..57fd4f1d7c 100644
--- a/app/Actions/Server/InstallPrerequisites.php
+++ b/app/Actions/Server/InstallPrerequisites.php
@@ -53,6 +53,8 @@ class InstallPrerequisites
"echo 'Installing Prerequisites for Arch Linux...'",
'pacman -Syu --noconfirm --needed curl wget git jq',
]);
+ } elseif ($supported_os_type->contains('alpine')) {
+ $command = $command->merge($this->getAlpinePrerequisiteCommands());
} else {
throw new \Exception('Unsupported OS type for prerequisites installation');
}
@@ -61,4 +63,18 @@ class InstallPrerequisites
return remote_process($command, $server);
}
+
+ private function getAlpinePrerequisiteCommands(): array
+ {
+ return [
+ "echo 'Installing Prerequisites for Alpine Linux...'",
+ "sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true",
+ 'apk update',
+ 'command -v bash >/dev/null || apk add bash',
+ 'command -v curl >/dev/null || apk add curl',
+ 'command -v wget >/dev/null || apk add wget',
+ 'command -v git >/dev/null || apk add git',
+ 'command -v jq >/dev/null || apk add jq',
+ ];
+ }
}
diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php
index 3a37a7328b..edcd4a1edd 100644
--- a/app/Actions/Server/StartSentinel.php
+++ b/app/Actions/Server/StartSentinel.php
@@ -10,6 +10,40 @@ class StartSentinel
{
use AsAction;
+ public static function trafficLogDirectory(Server $server): string
+ {
+ return isDev()
+ ? '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy'
+ : rtrim($server->proxyPath(), '/');
+ }
+
+ public static function sentinelTrafficEnvironment(Server $server): array
+ {
+ if (! $server->isTrafficAnalyticsEnabled()) {
+ return [];
+ }
+
+ $logPath = self::trafficLogDirectory($server).'/access.log';
+ $settings = $server->settings;
+ $env = [
+ 'TRAFFIC_ENABLED' => 'true',
+ 'TRAFFIC_PROXY_TYPE' => 'auto',
+ 'TRAFFIC_ACCESS_LOG_PATH' => $logPath,
+ 'TRAFFIC_TOPN' => (string) ($settings->traffic_topn ?: 50),
+ 'TRAFFIC_SAMPLE_THRESHOLD' => (string) ($settings->traffic_sample_threshold ?? 0),
+ 'TRAFFIC_RETENTION_1H_DAYS' => (string) ($settings->traffic_retention_1h_days ?: 30),
+ 'TRAFFIC_RETENTION_1D_DAYS' => (string) ($settings->traffic_retention_1d_days ?: 395),
+ 'GEOIP_ENABLED' => $settings->is_geoip_enabled ? 'true' : 'false',
+ 'GEOIP_REFRESH_DAYS' => (string) ($settings->geoip_refresh_days ?: 30),
+ ];
+ $license = data_get($settings, 'geoip_maxmind_license_key');
+ if ($settings->is_geoip_enabled && filled($license)) {
+ $env['GEOIP_MAXMIND_LICENSE_KEY'] = $license;
+ }
+
+ return $env;
+ }
+
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
{
if ($server->isSwarm() || $server->isBuildServer()) {
@@ -36,6 +70,7 @@ class StartSentinel
'COLLECTOR_REFRESH_RATE_SECONDS' => $refreshRate,
'COLLECTOR_RETENTION_PERIOD_DAYS' => $metricsHistory,
];
+ $environments = array_merge($environments, self::sentinelTrafficEnvironment($server));
$labels = [
'coolify.managed' => 'true',
];
@@ -48,7 +83,11 @@ class StartSentinel
}
$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-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
+ $trafficLogDirectory = self::trafficLogDirectory($server);
+ $trafficMount = $server->isTrafficAnalyticsEnabled()
+ ? '-v '.escapeshellarg("{$trafficLogDirectory}:{$trafficLogDirectory}:ro").' '
+ : '';
+ $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--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',
diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php
index ab0ca94943..2b06e06011 100644
--- a/app/Actions/Server/UpdatePackage.php
+++ b/app/Actions/Server/UpdatePackage.php
@@ -58,6 +58,10 @@ class UpdatePackage
$commandAll = 'pacman -Syu --noconfirm';
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
break;
+ case 'apk':
+ $commandAll = 'apk update && apk upgrade';
+ $commandInstall = 'apk upgrade '.$sanitizedPackage;
+ break;
default:
return [
'error' => 'OS not supported',
diff --git a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php
new file mode 100644
index 0000000000..e4a2ba0dfe
--- /dev/null
+++ b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php
@@ -0,0 +1,5 @@
+ $scopes
+ */
+ public function __construct(
+ public string $issuerUrl,
+ public string $clientId,
+ public string $clientSecret,
+ public string $redirectUri,
+ public array $scopes = ['openid', 'email', 'profile'],
+ public bool $usePkce = true,
+ public int $clockSkewSeconds = 60,
+ ) {}
+
+ public static function fromOauthSetting(OauthSetting $setting): self
+ {
+ return new self(
+ issuerUrl: rtrim((string) $setting->base_url, '/'),
+ clientId: (string) $setting->client_id,
+ clientSecret: (string) $setting->client_secret,
+ redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'),
+ scopes: $setting->scopeList(),
+ usePkce: $setting->use_pkce ?? true,
+ clockSkewSeconds: $setting->clock_skew_seconds ?? 60,
+ );
+ }
+}
diff --git a/app/Auth/Oidc/OidcDiscoveryDocument.php b/app/Auth/Oidc/OidcDiscoveryDocument.php
new file mode 100644
index 0000000000..d17061c51d
--- /dev/null
+++ b/app/Auth/Oidc/OidcDiscoveryDocument.php
@@ -0,0 +1,61 @@
+ $supportedScopes
+ * @param array $supportedClaims
+ * @param array $idTokenSigningAlgValuesSupported
+ */
+ public function __construct(
+ public string $issuer,
+ public string $authorizationEndpoint,
+ public string $tokenEndpoint,
+ public string $userinfoEndpoint,
+ public string $jwksUri,
+ public ?string $endSessionEndpoint = null,
+ public array $supportedScopes = [],
+ public array $supportedClaims = [],
+ public array $idTokenSigningAlgValuesSupported = [],
+ ) {}
+
+ /**
+ * @param array $payload
+ */
+ public static function fromArray(array $payload): self
+ {
+ foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) {
+ if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') {
+ throw new OidcDiscoveryException("Discovery document is missing required field: {$field}");
+ }
+ }
+
+ return new self(
+ issuer: $payload['issuer'],
+ authorizationEndpoint: $payload['authorization_endpoint'],
+ tokenEndpoint: $payload['token_endpoint'],
+ userinfoEndpoint: $payload['userinfo_endpoint'],
+ jwksUri: $payload['jwks_uri'],
+ endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null,
+ supportedScopes: self::stringList($payload['scopes_supported'] ?? []),
+ supportedClaims: self::stringList($payload['claims_supported'] ?? []),
+ idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []),
+ );
+ }
+
+ /**
+ * @return array
+ */
+ private static function stringList(mixed $value): array
+ {
+ if (! is_array($value)) {
+ return [];
+ }
+
+ return array_values(array_map('strval', $value));
+ }
+}
diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php
new file mode 100644
index 0000000000..0847afc9a7
--- /dev/null
+++ b/app/Auth/Oidc/OidcDiscoveryService.php
@@ -0,0 +1,97 @@
+assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.'));
+
+ $issuerUrl = rtrim($issuerUrl, '/');
+ $cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl);
+
+ return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument {
+ $url = $issuerUrl.'/.well-known/openid-configuration';
+
+ try {
+ $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url);
+ } catch (Throwable $e) {
+ throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e);
+ }
+
+ if ($response->failed()) {
+ throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}");
+ }
+
+ $json = $response->json();
+ if (! is_array($json) || $json === []) {
+ throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.');
+ }
+
+ $discovery = OidcDiscoveryDocument::fromArray($json);
+ if (rtrim($discovery->issuer, '/') !== $issuerUrl) {
+ throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.');
+ }
+
+ return $discovery;
+ });
+ }
+
+ /**
+ * Fetch the JWKS for the given URI.
+ *
+ * When $forceRefresh is true the cached document is bypassed so freshly
+ * rotated signing keys become visible immediately. A short cooldown still
+ * prevents a flood of upstream requests if many logins miss the same kid.
+ *
+ * @return array
+ */
+ public function jwks(string $jwksUri, bool $forceRefresh = false): array
+ {
+ $this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.'));
+
+ $cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri);
+
+ if ($forceRefresh) {
+ $cooldownKey = $cacheKey.':refresh';
+ if (Cache::add($cooldownKey, true, 60)) {
+ Cache::forget($cacheKey);
+ }
+ }
+
+ return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array {
+ try {
+ $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri);
+ } catch (Throwable $e) {
+ throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e);
+ }
+
+ if ($response->failed()) {
+ throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}");
+ }
+
+ $json = $response->json();
+ if (! is_array($json) || ! is_array($json['keys'] ?? null)) {
+ throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'.");
+ }
+
+ return $json;
+ });
+ }
+
+ private function assertHttpsUrl(string $url, Throwable $exception): void
+ {
+ $parts = parse_url($url);
+
+ if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') {
+ throw $exception;
+ }
+ }
+}
diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php
new file mode 100644
index 0000000000..a8563611dd
--- /dev/null
+++ b/app/Auth/Oidc/OidcTokenValidator.php
@@ -0,0 +1,199 @@
+ $jwks
+ * @return array
+ */
+ public function validate(
+ string $idToken,
+ OidcDiscoveryDocument $discovery,
+ array $jwks,
+ string $clientId,
+ ?string $expectedNonce = null,
+ int $clockSkewSeconds = 60,
+ ): array {
+ $kid = $this->extractKid($idToken);
+
+ try {
+ $keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM);
+ } catch (Throwable $e) {
+ throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e);
+ }
+
+ // Surface an unknown signing key distinctly so the caller can refresh
+ // the JWKS once (key rotation) before giving up.
+ if (! array_key_exists($kid, $keys)) {
+ throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
+ }
+
+ $previousLeeway = JWT::$leeway;
+ JWT::$leeway = $clockSkewSeconds;
+
+ try {
+ // Validates signature, header alg against the key alg (RS256),
+ // exp, nbf and iat. Throws on any failure.
+ $claims = (array) JWT::decode($idToken, $keys);
+ } catch (OidcTokenException $e) {
+ throw $e;
+ } catch (Throwable $e) {
+ throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e);
+ } finally {
+ JWT::$leeway = $previousLeeway;
+ }
+
+ $this->assertExpiry($claims);
+ $this->assertIssuer($claims, $discovery->issuer);
+ $this->assertAudience($claims, $clientId);
+ $this->assertNonce($claims, $expectedNonce);
+ $this->assertSubject($claims);
+
+ return $claims;
+ }
+
+ /**
+ * Drop JWKS entries explicitly marked for anything other than signing
+ * (e.g. "use":"enc") so they can never verify an id_token signature.
+ * firebase/php-jwt does not honour the "use" parameter on its own.
+ *
+ * @param array $jwks
+ * @return array
+ */
+ private function signingKeysOnly(array $jwks): array
+ {
+ $keys = array_values(array_filter(
+ $jwks['keys'] ?? [],
+ fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'),
+ ));
+
+ return ['keys' => $keys];
+ }
+
+ /**
+ * Decode just the JWT header to read the kid before signature
+ * verification, so an unknown key can be reported as a rotation miss.
+ */
+ private function extractKid(string $idToken): string
+ {
+ $segments = explode('.', $idToken);
+ if (count($segments) !== 3) {
+ throw new OidcTokenException('Malformed id_token.');
+ }
+
+ $header = json_decode($this->base64UrlDecode($segments[0]), true);
+ if (! is_array($header)) {
+ throw new OidcTokenException('id_token header contains invalid JSON.');
+ }
+
+ if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) {
+ throw new OidcTokenException('id_token uses a disallowed algorithm.');
+ }
+
+ $kid = $header['kid'] ?? null;
+ if (! is_string($kid) || $kid === '') {
+ throw new OidcTokenException('id_token header is missing kid.');
+ }
+
+ return $kid;
+ }
+
+ private function base64UrlDecode(string $value): string
+ {
+ $remainder = strlen($value) % 4;
+ if ($remainder !== 0) {
+ $value .= str_repeat('=', 4 - $remainder);
+ }
+
+ $decoded = base64_decode(strtr($value, '-_', '+/'), true);
+ if ($decoded === false) {
+ throw new OidcTokenException('Invalid base64url value in id_token header.');
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * @param array $claims
+ */
+ private function assertExpiry(array $claims): void
+ {
+ // Firebase enforces the exp window when present; OIDC requires it to exist.
+ if (! is_numeric($claims['exp'] ?? null)) {
+ throw new OidcTokenException('id_token is missing the exp claim.');
+ }
+ }
+
+ /**
+ * @param array $claims
+ */
+ private function assertSubject(array $claims): void
+ {
+ $subject = $claims['sub'] ?? null;
+ if (! is_string($subject) || $subject === '') {
+ throw new OidcTokenException('id_token subject is missing or invalid.');
+ }
+ }
+
+ /**
+ * @param array $claims
+ */
+ private function assertIssuer(array $claims, string $expectedIssuer): void
+ {
+ if (($claims['iss'] ?? null) !== $expectedIssuer) {
+ throw new OidcTokenException('id_token issuer does not match discovery issuer.');
+ }
+ }
+
+ /**
+ * @param array $claims
+ */
+ private function assertAudience(array $claims, string $clientId): void
+ {
+ $audience = $claims['aud'] ?? null;
+ if (is_string($audience)) {
+ $audience = [$audience];
+ }
+
+ if (! is_array($audience) || ! in_array($clientId, $audience, true)) {
+ throw new OidcTokenException('id_token audience does not include configured client id.');
+ }
+
+ if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) {
+ throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.');
+ }
+
+ if (isset($claims['azp']) && $claims['azp'] !== $clientId) {
+ throw new OidcTokenException('id_token azp does not match configured client id.');
+ }
+ }
+
+ /**
+ * @param array $claims
+ */
+ private function assertNonce(array $claims, ?string $expectedNonce): void
+ {
+ if ($expectedNonce === null) {
+ return;
+ }
+
+ if (($claims['nonce'] ?? null) !== $expectedNonce) {
+ throw new OidcTokenException('id_token nonce does not match.');
+ }
+ }
+}
diff --git a/app/Auth/Oidc/OidcUser.php b/app/Auth/Oidc/OidcUser.php
new file mode 100644
index 0000000000..645130e019
--- /dev/null
+++ b/app/Auth/Oidc/OidcUser.php
@@ -0,0 +1,32 @@
+
+ */
+ public array $idTokenClaims = [];
+
+ /**
+ * @param array $claims
+ */
+ public function setIdTokenClaims(array $claims): self
+ {
+ $this->idTokenClaims = $claims;
+ $this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null;
+ $this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null;
+ $this->emailVerified = ($claims['email_verified'] ?? false) === true;
+
+ return $this;
+ }
+}
diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php
new file mode 100644
index 0000000000..383b0cc910
--- /dev/null
+++ b/app/Auth/Oidc/Socialite/OidcProvider.php
@@ -0,0 +1,299 @@
+
+ */
+ protected $scopes = ['openid', 'email', 'profile'];
+
+ protected $scopeSeparator = ' ';
+
+ protected ?OidcConfig $oidcConfig = null;
+
+ protected ?OidcDiscoveryDocument $discovery = null;
+
+ public function __construct(
+ Request $request,
+ protected OidcDiscoveryService $discoveryService,
+ protected OidcTokenValidator $tokenValidator,
+ string $clientId,
+ string $clientSecret,
+ string $redirectUrl,
+ ) {
+ parent::__construct($request, $clientId, $clientSecret, $redirectUrl);
+ }
+
+ public function setConfig(OidcConfig $config): self
+ {
+ $this->oidcConfig = $config;
+ $this->clientId = $config->clientId;
+ $this->clientSecret = $config->clientSecret;
+ $this->redirectUrl = $config->redirectUri;
+ $this->scopes = $config->scopes;
+ $this->discovery = null;
+
+ return $this;
+ }
+
+ public function getConfig(): OidcConfig
+ {
+ if ($this->oidcConfig === null) {
+ throw new OidcException('OIDC provider config is not set.');
+ }
+
+ return $this->oidcConfig;
+ }
+
+ protected function getAuthUrl($state): string
+ {
+ $config = $this->getConfig();
+ $nonce = Str::random(40);
+ $this->putOidcFlowValue($this->nonceSessionKey($state), $nonce);
+
+ $extra = ['nonce' => $nonce];
+ if ($config->usePkce) {
+ $verifier = $this->generateCodeVerifier();
+ $this->putOidcFlowValue($this->verifierSessionKey($state), $verifier);
+ $extra['code_challenge'] = $this->codeChallenge($verifier);
+ $extra['code_challenge_method'] = 'S256';
+ }
+
+ return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state)
+ .'&'.http_build_query($extra, '', '&', $this->encodingType);
+ }
+
+ protected function getTokenUrl(): string
+ {
+ return $this->resolveDiscovery()->tokenEndpoint;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getUserByToken($token): array
+ {
+ $response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [
+ RequestOptions::HEADERS => [
+ 'Accept' => 'application/json',
+ 'Authorization' => 'Bearer '.$token,
+ ],
+ RequestOptions::CONNECT_TIMEOUT => 5,
+ RequestOptions::TIMEOUT => 10,
+ ]);
+
+ $decoded = json_decode((string) $response->getBody(), true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * @param array $user
+ */
+ protected function mapUserToObject(array $user)
+ {
+ return (new OidcUser)->setRaw($user)->map([
+ 'id' => $user['sub'] ?? null,
+ 'nickname' => $user['preferred_username'] ?? null,
+ 'name' => $this->resolveName($user),
+ 'email' => $user['email'] ?? null,
+ 'avatar' => $user['picture'] ?? null,
+ ]);
+ }
+
+ public function user()
+ {
+ if ($this->user) {
+ return $this->user;
+ }
+
+ if ($this->hasInvalidState()) {
+ throw new InvalidStateException;
+ }
+
+ $tokenResponse = $this->getAccessTokenResponse($this->getCode());
+ $accessToken = Arr::get($tokenResponse, 'access_token');
+ $idToken = Arr::get($tokenResponse, 'id_token');
+
+ if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') {
+ throw new OidcException('OIDC token endpoint did not return required tokens.');
+ }
+
+ $discovery = $this->resolveDiscovery();
+ $config = $this->getConfig();
+ $expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state')));
+ if ($expectedNonce === null) {
+ throw new OidcException('OIDC login session expired. Please try again.');
+ }
+
+ $claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce);
+
+ $userinfo = $this->getUserByToken($accessToken);
+
+ // OIDC core Β§5.3.2: the userinfo sub MUST match the id_token sub.
+ // Reject the response rather than trust unsigned userinfo claims.
+ $userinfoSub = $userinfo['sub'] ?? null;
+ if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) {
+ throw new OidcException('OIDC userinfo subject does not match the id_token subject.');
+ }
+
+ $merged = array_merge($userinfo, $claims);
+
+ /** @var OidcUser $user */
+ $user = $this->mapUserToObject($merged);
+ $user->setIdTokenClaims($claims)
+ ->setToken($accessToken)
+ ->setRefreshToken(Arr::get($tokenResponse, 'refresh_token'))
+ ->setExpiresIn(Arr::get($tokenResponse, 'expires_in'));
+
+ return $this->user = $user;
+ }
+
+ /**
+ * Validate the id_token, retrying once against a freshly fetched JWKS when
+ * the signing key is unknown. This keeps logins working immediately after
+ * the IdP rotates keys instead of failing until the JWKS cache expires.
+ *
+ * @return array
+ */
+ protected function validateIdToken(
+ string $idToken,
+ OidcDiscoveryDocument $discovery,
+ OidcConfig $config,
+ ?string $expectedNonce,
+ ): array {
+ foreach ([false, true] as $forceRefresh) {
+ try {
+ return $this->tokenValidator->validate(
+ idToken: $idToken,
+ discovery: $discovery,
+ jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh),
+ clientId: $config->clientId,
+ expectedNonce: $expectedNonce,
+ clockSkewSeconds: $config->clockSkewSeconds,
+ );
+ } catch (OidcSigningKeyNotFoundException $e) {
+ if ($forceRefresh) {
+ throw $e;
+ }
+ }
+ }
+
+ throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
+ }
+
+ /**
+ * @return array
+ */
+ public function getAccessTokenResponse($code)
+ {
+ $fields = $this->getTokenFields($code);
+ if ($this->getConfig()->usePkce) {
+ $verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state')));
+ if ($verifier === null) {
+ throw new OidcException('OIDC login session expired. Please try again.');
+ }
+
+ $fields['code_verifier'] = $verifier;
+ }
+
+ $response = $this->getHttpClient()->post($this->getTokenUrl(), [
+ RequestOptions::HEADERS => ['Accept' => 'application/json'],
+ RequestOptions::FORM_PARAMS => $fields,
+ RequestOptions::CONNECT_TIMEOUT => 5,
+ RequestOptions::TIMEOUT => 10,
+ ]);
+
+ $decoded = json_decode((string) $response->getBody(), true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ protected function resolveDiscovery(): OidcDiscoveryDocument
+ {
+ return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl);
+ }
+
+ protected function generateCodeVerifier(): string
+ {
+ return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
+ }
+
+ protected function codeChallenge(string $verifier): string
+ {
+ return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
+ }
+
+ /**
+ * @param array $user
+ */
+ protected function resolveName(array $user): ?string
+ {
+ if (is_string($user['name'] ?? null) && $user['name'] !== '') {
+ return $user['name'];
+ }
+
+ $name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? '')));
+
+ return $name === '' ? null : $name;
+ }
+
+ protected function putOidcFlowValue(string $key, string $value): void
+ {
+ $this->request->session()->put($key, [
+ 'value' => $value,
+ 'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp,
+ ]);
+ }
+
+ protected function pullOidcFlowValue(string $key): ?string
+ {
+ $entry = $this->request->session()->pull($key);
+
+ if (! is_array($entry)) {
+ return null;
+ }
+
+ $value = $entry['value'] ?? null;
+ $expiresAt = $entry['expires_at'] ?? null;
+
+ if (! is_string($value) || $value === '' || ! is_int($expiresAt)) {
+ return null;
+ }
+
+ if ($expiresAt < now()->timestamp) {
+ return null;
+ }
+
+ return $value;
+ }
+
+ protected function nonceSessionKey(string $state): string
+ {
+ return "oidc.nonce.{$state}";
+ }
+
+ protected function verifierSessionKey(string $state): string
+ {
+ return "oidc.code_verifier.{$state}";
+ }
+}
diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php
index 347ea94193..65f686ba61 100644
--- a/app/Console/Commands/CleanupDatabase.php
+++ b/app/Console/Commands/CleanupDatabase.php
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
+use App\Models\AuditEvent;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@@ -49,6 +50,12 @@ class CleanupDatabase extends Command
$activity_log->delete();
}
+ $count = DB::table('audit_events')->where('created_at', '<', now()->subDays(90))->count();
+ echo "Delete $count entries from audit_events.\n";
+ if ($this->option('yes')) {
+ AuditEvent::pruneExpired();
+ }
+
// Cleanup application_deployment_queues table
$application_deployment_queues = DB::table('application_deployment_queues')->where('created_at', '<', now()->subDays($keep_days))->orderBy('created_at', 'desc')->skip(10);
$count = $application_deployment_queues->count();
diff --git a/app/Data/Traffic/TrafficBreakdownData.php b/app/Data/Traffic/TrafficBreakdownData.php
new file mode 100644
index 0000000000..a98e5323a4
--- /dev/null
+++ b/app/Data/Traffic/TrafficBreakdownData.php
@@ -0,0 +1,23 @@
+teamId}")];
+ }
+}
diff --git a/app/Exceptions/DnsRecordConflictException.php b/app/Exceptions/DnsRecordConflictException.php
new file mode 100644
index 0000000000..00f5c73b72
--- /dev/null
+++ b/app/Exceptions/DnsRecordConflictException.php
@@ -0,0 +1,16 @@
+/dev/null 2>&1; then exec bash -se; else exec sh -se; fi';
+ }
+
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
diff --git a/app/Http/Controllers/Api/ApplicationSecretManagerController.php b/app/Http/Controllers/Api/ApplicationSecretManagerController.php
new file mode 100644
index 0000000000..c8c311766d
--- /dev/null
+++ b/app/Http/Controllers/Api/ApplicationSecretManagerController.php
@@ -0,0 +1,123 @@
+ []]],
+ tags: ['Secret Managers'],
+ parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
+ requestBody: new OA\RequestBody(
+ required: true,
+ content: new OA\JsonContent(
+ required: ['integration_token_uuid'],
+ properties: [
+ new OA\Property(property: 'integration_token_uuid', type: 'string'),
+ new OA\Property(property: 'settings', type: 'object'),
+ ],
+ ),
+ ),
+ responses: [
+ new OA\Response(response: 200, description: 'Secret manager configured.'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ],
+ )]
+ public function update(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $application = Application::ownedByCurrentTeamAPI($teamId)
+ ->where('uuid', $request->route('uuid'))
+ ->first();
+
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ $this->authorize('update', $application);
+
+ $body = $request->json()->all();
+ $token = IntegrationToken::query()
+ ->where('team_id', $teamId)
+ ->where('uuid', $body['integration_token_uuid'] ?? '')
+ ->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
+ ->first();
+
+ if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) {
+ return response()->json(['message' => 'Secret manager integration token not found.'], 404);
+ }
+
+ $rules = [
+ 'integration_token_uuid' => ['required', 'string'],
+ 'settings' => ['sometimes', 'array'],
+ ];
+ $rules += match ($token->provider) {
+ 'doppler' => $token->dopplerTokenType() === 'service_account' ? [
+ 'settings.project' => ['required', 'string'],
+ 'settings.config' => ['required', 'string'],
+ ] : [],
+ 'infisical' => [
+ 'settings.project_id' => ['required', 'string'],
+ 'settings.environment' => ['required', 'string'],
+ 'settings.secret_path' => ['nullable', 'string'],
+ ],
+ 'vault' => [
+ 'settings.mount' => ['required', 'string'],
+ 'settings.path' => ['required', 'string'],
+ ],
+ default => [],
+ };
+
+ $validator = customApiValidator($body, $rules);
+ $extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']);
+
+ if ($validator->fails() || $extraFields !== []) {
+ $errors = $validator->errors();
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+
+ return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
+ }
+
+ $settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value));
+ $application->secretManagerLink()->updateOrCreate([], [
+ 'integration_token_id' => $token->id,
+ 'settings' => $settings ?: null,
+ ]);
+
+ auditLog('api.application.secret_manager.updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'integration_token_uuid' => $token->uuid,
+ ]);
+
+ return response()->json([
+ 'integration_token_uuid' => $token->uuid,
+ 'provider' => $token->provider,
+ 'settings' => $settings ?: null,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 67fd515bcd..4583600995 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -10,6 +10,7 @@ use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
+use App\Models\ApplicationSetting;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
@@ -61,6 +62,7 @@ class ApplicationsController extends Controller
'gpu_options',
'is_consistent_container_name_enabled',
'custom_internal_name',
+ 'custom_container_name_prefix',
];
private const BOOLEAN_APPLICATION_SETTING_FIELDS = [
@@ -153,9 +155,26 @@ class ApplicationsController extends Controller
: $request->input($field);
}
+ if (array_key_exists('custom_container_name_prefix', $settings)) {
+ $settings['custom_container_name_prefix'] = str($settings['custom_container_name_prefix'])->slug()->value() ?: null;
+ }
+
return $settings;
}
+ private function containerNamePrefixValidationResponse(array $settings, Server $server, ?Application $application = null): ?JsonResponse
+ {
+ $prefix = $settings['custom_container_name_prefix'] ?? null;
+ if (! filled($prefix) || ! ApplicationSetting::isContainerNamePrefixInUse($prefix, $server, $application?->id)) {
+ return null;
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['custom_container_name_prefix' => ['This container name prefix is already in use by another application.']],
+ ], 422);
+ }
+
private function applyApplicationSettings(Application $application, array $settings): void
{
if ($settings === []) {
@@ -393,6 +412,7 @@ class ApplicationsController extends Controller
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
+ 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -587,6 +607,7 @@ class ApplicationsController extends Controller
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
+ 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -781,6 +802,7 @@ class ApplicationsController extends Controller
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
+ 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -946,6 +968,7 @@ class ApplicationsController extends Controller
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
+ 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -1107,6 +1130,7 @@ class ApplicationsController extends Controller
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
+ 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
@@ -1335,6 +1359,9 @@ class ApplicationsController extends Controller
], 422);
}
}
+ if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $destination->server)) {
+ return $prefixValidation;
+ }
if ($type === 'public') {
$validationRules = [
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
@@ -2969,6 +2996,7 @@ class ApplicationsController extends Controller
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
+ 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
@@ -3142,6 +3170,9 @@ class ApplicationsController extends Controller
}
$applicationSettings = $this->applicationSettingsFromRequest($request);
+ if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) {
+ return $prefixValidation;
+ }
$requestedBuildPack = $request->input('build_pack', $application->build_pack);
if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') {
return response()->json([
@@ -3395,7 +3426,7 @@ class ApplicationsController extends Controller
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
}
- $application->save();
+ $application->withoutAuditLogging(fn () => $application->save());
auditLog('api.application.updated', [
'team_id' => $teamId,
@@ -5903,14 +5934,6 @@ class ApplicationsController extends Controller
return response()->json(['message' => $result['message']], 200);
}
- auditLog('api.application.rollback', [
- 'team_id' => $teamId,
- 'application_uuid' => $application->uuid,
- 'application_name' => $application->name,
- 'deployment_uuid' => $deployment_uuid,
- 'commit' => $commit,
- ]);
-
return response()->json([
'message' => 'Rollback deployment queued.',
'deployment_uuid' => $deployment_uuid,
diff --git a/app/Http/Controllers/Api/AuditEventsController.php b/app/Http/Controllers/Api/AuditEventsController.php
new file mode 100644
index 0000000000..da452bb303
--- /dev/null
+++ b/app/Http/Controllers/Api/AuditEventsController.php
@@ -0,0 +1,80 @@
+user()->isAdminOfTeam($teamId)) {
+ return response()->json(['message' => 'Only team admins and owners can view audit logs.'], 403);
+ }
+
+ $validator = Validator::make($request->all(), [
+ 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
+ 'page' => ['sometimes', 'integer', 'min:1'],
+ 'search' => ['sometimes', 'nullable', 'string', 'max:255'],
+ 'action' => ['sometimes', 'nullable', 'string', 'max:255'],
+ 'source' => ['sometimes', 'nullable', 'string', Rule::in(['all', 'ui', 'api', 'mcp', 'webhook', 'system', 'scheduler'])],
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ $validated = $validator->validated();
+ $perPage = (int) ($validated['per_page'] ?? 25);
+ $search = trim((string) ($validated['search'] ?? ''));
+ $canReadSensitive = $request->attributes->get('can_read_sensitive', false) === true;
+ $events = AuditEvent::query()
+ ->select([
+ 'id',
+ 'team_id',
+ 'event',
+ 'source',
+ 'action',
+ 'actor_type',
+ 'actor_id',
+ 'actor_name',
+ 'resource_type',
+ 'resource_uuid',
+ 'resource_name',
+ 'description',
+ 'created_at',
+ ])
+ ->when($canReadSensitive, fn ($query) => $query->addSelect([
+ 'actor_email',
+ 'actor_token_id',
+ 'actor_token_name',
+ 'metadata',
+ 'ip_address',
+ 'user_agent',
+ ]))
+ ->visibleToTeam($teamId)
+ ->filtered(
+ search: $search,
+ action: (string) ($validated['action'] ?? 'all'),
+ source: (string) ($validated['source'] ?? 'all'),
+ searchSensitiveFields: $canReadSensitive,
+ )
+ ->latestFirst()
+ ->paginate($perPage);
+
+ return response()->json(serializeApiResponse($events));
+ }
+}
diff --git a/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php
new file mode 100644
index 0000000000..a2ec2b93be
--- /dev/null
+++ b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php
@@ -0,0 +1,125 @@
+authorize('uploadBackup', $resource);
+ $validator = Validator::make($request->all(), ['upload_id' => ['required', 'uuid'], 'file' => ['required', 'file']]);
+ if ($validator->fails()) {
+ return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
+ }
+ $originalName = $request->file('file')?->getClientOriginalName();
+ if (! $originalName || ! DatabaseBackupFileValidator::hasAllowedExtension($originalName)) {
+ return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Unsupported backup file extension.']]], 422);
+ }
+ if ((int) $request->input('dzTotalFilesize', 0) > StartDatabaseImport::MAX_BYTES) {
+ return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['The backup exceeds the 10 GiB limit.']]], 422);
+ }
+
+ $request->merge(['dzuuid' => $request->input('dzuuid', $request->string('upload_id')->value())]);
+ $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
+ $save = $receiver->receive();
+ if (! $save->isFinished()) {
+ return response()->json(['upload_id' => $request->string('upload_id')->value(), 'done' => $save->handler()->getPercentageDone(), 'status' => true]);
+ }
+
+ $file = $save->getFile();
+ if (! $file instanceof UploadedFile || ! DatabaseBackupFileValidator::isUploadAllowed($file, StartDatabaseImport::MAX_BYTES)) {
+ @unlink($file->getPathname());
+
+ return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Uploaded file failed validation.']]], 422);
+ }
+ $mimeType = $file->getMimeType();
+ $size = $file->getSize();
+ $directory = "upload/imports/{$teamId}/{$resource->uuid}/{$request->string('upload_id')->value()}";
+ Storage::makeDirectory($directory);
+ $file->move(Storage::path($directory), 'restore');
+
+ return response()->json(['upload_id' => $request->string('upload_id')->value(), 'filename' => $originalName, 'mime_type' => $mimeType, 'size' => $size], 201);
+ }
+
+ protected function startDatabaseImport(Request $request, Model $resource, int $teamId, string $statusRoute, array $routeParameters): JsonResponse
+ {
+ $this->authorize('update', $resource);
+ $payload = $request->json()->all() ?: $request->request->all();
+ $allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing'];
+ $validator = Validator::make($payload, [
+ 'source' => ['required', Rule::in(['upload', 's3', 'server'])],
+ 'upload_id' => ['required_if:source,upload', 'prohibited_unless:source,upload', 'uuid'],
+ 's3_storage_uuid' => ['required_if:source,s3', 'prohibited_unless:source,s3', 'string'],
+ 'path' => ['required_if:source,s3,server', 'prohibited_if:source,upload', 'string', 'max:4096'],
+ 'dump_all' => ['sometimes', 'boolean'],
+ 'replace_existing' => ['sometimes', 'boolean'],
+ ]);
+ $extraFields = array_diff(array_keys($payload), $allowed);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+
+ return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
+ }
+
+ try {
+ $source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false), (bool) ($payload['replace_existing'] ?? false));
+ $activity = app(StartDatabaseImport::class)->handle($resource, $source, $teamId);
+ } catch (DatabaseImportException $exception) {
+ return response()->json(['message' => $exception->getMessage()], $exception->status);
+ }
+ auditLog('api.database.import_started', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $resource->uuid,
+ 'database_name' => $resource->name,
+ 'source' => $source->type,
+ 'replace_existing' => $source->replaceExisting,
+ 'activity_id' => $activity->id,
+ ]);
+ $url = route($statusRoute, [...$routeParameters, 'activity_id' => $activity->id], false);
+
+ return response()->json(['id' => $activity->id, 'status' => data_get($activity, 'properties.status'), 'message' => 'Database import queued.', 'status_url' => $url], 202)->header('Location', $url);
+ }
+
+ protected function showDatabaseImport(Model $resource, int $teamId, int $activityId): JsonResponse
+ {
+ $this->authorize('view', $resource);
+ $activity = Activity::query()->whereKey($activityId)
+ ->where('properties->team_id', $teamId)
+ ->where('properties->type_uuid', $resource->uuid)
+ ->where('properties->operation', 'database_import')->first();
+ if (! $activity) {
+ return response()->json(['message' => 'Database import not found.'], 404);
+ }
+ $status = data_get($activity, 'properties.status');
+ $terminal = in_array($status, ['finished', 'error', 'killed', 'cancelled', 'closed'], true);
+
+ return response()->json([
+ 'id' => $activity->id,
+ 'status' => $status,
+ 'exit_code' => data_get($activity, 'properties.exitCode'),
+ 'output' => remove_iip(RunRemoteProcess::decodeOutput($activity)),
+ 'created_at' => $activity->created_at,
+ 'updated_at' => $activity->updated_at,
+ 'finished_at' => $terminal ? $activity->updated_at : null,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 881aa5a897..6d9fa1cec7 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -32,8 +32,87 @@ use OpenApi\Attributes as OA;
class DatabasesController extends Controller
{
+ use Concerns\HandlesDatabaseImportsApi;
use Concerns\HandlesTagsApi;
+ #[OA\Post(
+ path: '/databases/{uuid}/imports/uploads',
+ operationId: 'upload-database-import',
+ summary: 'Upload database import',
+ security: [['bearerAuth' => []]],
+ tags: ['Databases'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(response: 201, description: 'Upload completed'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function upload_import(Request $request, string $uuid): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ $database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
+
+ return $database ? $this->uploadDatabaseImport($request, $database, $teamId) : response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ #[OA\Post(
+ path: '/databases/{uuid}/imports',
+ operationId: 'create-database-import',
+ summary: 'Import database backup',
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')),
+ security: [['bearerAuth' => []]],
+ tags: ['Databases'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(response: 202, description: 'Import queued'),
+ new OA\Response(response: 409, description: 'Import already active'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function create_import(Request $request, string $uuid): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ $database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
+
+ return $database ? $this->startDatabaseImport($request, $database, $teamId, 'api.databases.imports.show', ['uuid' => $uuid]) : response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ #[OA\Get(
+ path: '/databases/{uuid}/imports/{activity_id}',
+ operationId: 'get-database-import',
+ summary: 'Get database import status',
+ security: [['bearerAuth' => []]],
+ tags: ['Databases'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(name: 'activity_id', in: 'path', required: true, description: 'Import activity ID.', schema: new OA\Schema(type: 'integer')),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Import status', content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportStatus')),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ ]
+ )]
+ public function show_import(Request $request, string $uuid, int $activity_id): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ $database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
+
+ return $database ? $this->showDatabaseImport($database, $teamId, $activity_id) : response()->json(['message' => 'Database not found.'], 404);
+ }
+
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
diff --git a/app/Http/Controllers/Api/IntegrationTokensController.php b/app/Http/Controllers/Api/IntegrationTokensController.php
new file mode 100644
index 0000000000..13a225a107
--- /dev/null
+++ b/app/Http/Controllers/Api/IntegrationTokensController.php
@@ -0,0 +1,108 @@
+ []]],
+ tags: ['Secret Managers'],
+ requestBody: new OA\RequestBody(
+ required: true,
+ content: new OA\JsonContent(
+ required: ['provider', 'name', 'token'],
+ properties: [
+ new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']),
+ new OA\Property(property: 'name', type: 'string'),
+ new OA\Property(property: 'token', type: 'string'),
+ new OA\Property(property: 'metadata', type: 'object'),
+ ],
+ ),
+ ),
+ responses: [
+ new OA\Response(response: 201, description: 'Integration token created.'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ],
+ )]
+ public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $this->authorize('create', IntegrationToken::class);
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $body = $request->json()->all();
+ $rules = [
+ 'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)],
+ 'name' => ['required', 'string', 'max:255'],
+ 'token' => ['required', 'string'],
+ 'metadata' => ['sometimes', 'array'],
+ ];
+
+ if (($body['provider'] ?? null) === 'doppler') {
+ $rules['token'][] = 'regex:/^dp\.(st|sa)\./';
+ } elseif (($body['provider'] ?? null) === 'infisical') {
+ $rules['metadata.base_url'] = ['required', 'url:http,https'];
+ $rules['metadata.client_id'] = ['required', 'string'];
+ } elseif (($body['provider'] ?? null) === 'vault') {
+ $rules['metadata.base_url'] = ['required', 'url:http,https'];
+ $rules['metadata.namespace'] = ['nullable', 'string'];
+ }
+
+ $validator = customApiValidator($body, $rules);
+ $extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']);
+
+ if ($validator->fails() || $extraFields !== []) {
+ $errors = $validator->errors();
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+
+ return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
+ }
+
+ $validated = $validator->validated();
+ $metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value));
+
+ if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) {
+ return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400);
+ }
+
+ $integrationToken = IntegrationToken::query()->create([
+ 'team_id' => $teamId,
+ 'provider' => $validated['provider'],
+ 'name' => $validated['name'],
+ 'token' => $validated['token'],
+ 'capabilities' => ['secrets'],
+ 'metadata' => $metadata ?: null,
+ ]);
+
+ auditLog('api.integration_token.created', [
+ 'team_id' => $teamId,
+ 'integration_token_uuid' => $integrationToken->uuid,
+ 'provider' => $integrationToken->provider,
+ ]);
+
+ return response()->json(['uuid' => $integrationToken->uuid], 201);
+ }
+}
diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php
index 33d21ba5d0..43ce742168 100644
--- a/app/Http/Controllers/Api/OpenApi.php
+++ b/app/Http/Controllers/Api/OpenApi.php
@@ -12,6 +12,30 @@ use OpenApi\Attributes as OA;
securityScheme: 'bearerAuth',
description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.')]
#[OA\Components(
+ schemas: [
+ new OA\Schema(
+ schema: 'DatabaseImportRequest',
+ oneOf: [
+ new OA\Schema(required: ['source', 'upload_id'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['upload']), new OA\Property(property: 'upload_id', type: 'string', format: 'uuid'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
+ new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['s3']), new OA\Property(property: 's3_storage_uuid', type: 'string'), new OA\Property(property: 'path', type: 'string'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
+ new OA\Schema(required: ['source', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['server']), new OA\Property(property: 'path', type: 'string', example: '/var/backups/database.sql.gz'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
+ ],
+ type: 'object',
+ ),
+ new OA\Schema(
+ schema: 'DatabaseImportStatus',
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'id', type: 'integer'),
+ new OA\Property(property: 'status', type: 'string', enum: ['queued', 'in_progress', 'finished', 'error', 'killed', 'cancelled', 'closed']),
+ new OA\Property(property: 'exit_code', type: 'integer', nullable: true),
+ new OA\Property(property: 'output', type: 'string'),
+ new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
+ new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
+ new OA\Property(property: 'finished_at', type: 'string', format: 'date-time', nullable: true),
+ ],
+ ),
+ ],
responses: [
new OA\Response(
response: 400,
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index eb137c5349..16eff1ba18 100644
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -271,12 +271,6 @@ class ProjectController extends Controller
'team_id' => $teamId,
]);
- auditLog('api.project.created', [
- 'team_id' => $teamId,
- 'project_uuid' => $project->uuid,
- 'project_name' => $project->name,
- ]);
-
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@@ -396,13 +390,6 @@ class ProjectController extends Controller
$project->update($request->only($allowedFields));
- auditLog('api.project.updated', [
- 'team_id' => $teamId,
- 'project_uuid' => $project->uuid,
- 'project_name' => $project->name,
- 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
- ]);
-
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@@ -482,16 +469,8 @@ class ProjectController extends Controller
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
- $projectUuid = $project->uuid;
- $projectName = $project->name;
$project->delete();
- auditLog('api.project.deleted', [
- 'team_id' => $teamId,
- 'project_uuid' => $projectUuid,
- 'project_name' => $projectName,
- ]);
-
return response()->json(['message' => 'Project deleted.']);
}
diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php
index fb40745c9e..77bc16c1c2 100644
--- a/app/Http/Controllers/Api/ServerSentinelController.php
+++ b/app/Http/Controllers/Api/ServerSentinelController.php
@@ -19,6 +19,13 @@ class ServerSentinelController extends Controller
'sentinel_metrics_history_days',
'sentinel_push_interval_seconds',
'sentinel_custom_url',
+ 'traffic_topn',
+ 'traffic_sample_threshold',
+ 'traffic_retention_1h_days',
+ 'traffic_retention_1d_days',
+ 'is_geoip_enabled',
+ 'geoip_refresh_days',
+ 'geoip_maxmind_license_key',
];
private function findServerForTeam(int $teamId, string $uuid): ?Server
@@ -42,11 +49,18 @@ class ServerSentinelController extends Controller
'sentinel_metrics_history_days' => (int) $settings->sentinel_metrics_history_days,
'sentinel_push_interval_seconds' => (int) $settings->sentinel_push_interval_seconds,
'sentinel_updated_at' => $server->sentinel_updated_at,
+ 'traffic_topn' => (int) $settings->traffic_topn,
+ 'traffic_sample_threshold' => (int) $settings->traffic_sample_threshold,
+ 'traffic_retention_1h_days' => (int) $settings->traffic_retention_1h_days,
+ 'traffic_retention_1d_days' => (int) $settings->traffic_retention_1d_days,
+ 'is_geoip_enabled' => (bool) $settings->is_geoip_enabled,
+ 'geoip_refresh_days' => (int) $settings->geoip_refresh_days,
];
if ($this->canReadSensitive()) {
$payload['sentinel_token'] = $settings->sentinel_token;
$payload['sentinel_custom_url'] = $settings->sentinel_custom_url;
+ $payload['geoip_maxmind_license_key'] = $settings->geoip_maxmind_license_key;
}
return $payload;
@@ -77,6 +91,13 @@ class ServerSentinelController extends Controller
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer'),
new OA\Property(property: 'sentinel_custom_url', type: 'string', description: 'Only present with read:sensitive.'),
new OA\Property(property: 'sentinel_updated_at', type: 'string', nullable: true),
+ new OA\Property(property: 'traffic_topn', type: 'integer'),
+ new OA\Property(property: 'traffic_sample_threshold', type: 'integer'),
+ new OA\Property(property: 'traffic_retention_1h_days', type: 'integer'),
+ new OA\Property(property: 'traffic_retention_1d_days', type: 'integer'),
+ new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
+ new OA\Property(property: 'geoip_refresh_days', type: 'integer'),
+ new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', description: 'Only present with read:sensitive.'),
],
type: 'object',
),
@@ -124,6 +145,13 @@ class ServerSentinelController extends Controller
new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer', minimum: 1),
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer', minimum: 10),
new OA\Property(property: 'sentinel_custom_url', type: 'string', nullable: true),
+ new OA\Property(property: 'traffic_topn', type: 'integer', minimum: 1),
+ new OA\Property(property: 'traffic_sample_threshold', type: 'integer', minimum: 0),
+ new OA\Property(property: 'traffic_retention_1h_days', type: 'integer', minimum: 1),
+ new OA\Property(property: 'traffic_retention_1d_days', type: 'integer', minimum: 1),
+ new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
+ new OA\Property(property: 'geoip_refresh_days', type: 'integer', minimum: 1),
+ new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', nullable: true),
],
type: 'object',
),
@@ -163,6 +191,13 @@ class ServerSentinelController extends Controller
'sentinel_metrics_history_days' => 'integer|min:1',
'sentinel_push_interval_seconds' => 'integer|min:10',
'sentinel_custom_url' => 'nullable|url',
+ 'traffic_topn' => 'integer|min:1',
+ 'traffic_sample_threshold' => 'integer|min:0',
+ 'traffic_retention_1h_days' => 'integer|min:1',
+ 'traffic_retention_1d_days' => 'integer|min:1',
+ 'is_geoip_enabled' => 'boolean',
+ 'geoip_refresh_days' => 'integer|min:1',
+ 'geoip_maxmind_license_key' => 'nullable|string|max:255',
]);
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php
index 480ff4e557..81e68ceb31 100644
--- a/app/Http/Controllers/Api/ServiceDatabasesController.php
+++ b/app/Http/Controllers/Api/ServiceDatabasesController.php
@@ -18,6 +18,84 @@ use OpenApi\Attributes as OA;
class ServiceDatabasesController extends Controller
{
+ use Concerns\HandlesDatabaseImportsApi;
+
+ #[OA\Post(
+ path: '/services/{uuid}/databases/{database_uuid}/imports/uploads',
+ operationId: 'upload-service-database-import',
+ summary: 'Upload service database import',
+ security: [['bearerAuth' => []]],
+ tags: ['Service databases'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(response: 201, description: 'Upload completed'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function upload_import(Request $request): JsonResponse
+ {
+ return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->uploadDatabaseImport($request, $database, $teamId));
+ }
+
+ #[OA\Post(
+ path: '/services/{uuid}/databases/{database_uuid}/imports',
+ operationId: 'create-service-database-import',
+ summary: 'Import service database backup',
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')),
+ security: [['bearerAuth' => []]],
+ tags: ['Service databases'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(response: 202, description: 'Import queued'),
+ new OA\Response(response: 409, description: 'Import already active'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function create_import(Request $request): JsonResponse
+ {
+ return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->startDatabaseImport($request, $database, $teamId, 'api.service-databases.imports.show', ['uuid' => $request->route('uuid'), 'database_uuid' => $database->uuid]));
+ }
+
+ #[OA\Get(
+ path: '/services/{uuid}/databases/{database_uuid}/imports/{activity_id}',
+ operationId: 'get-service-database-import',
+ summary: 'Get service database import status',
+ security: [['bearerAuth' => []]],
+ tags: ['Service databases'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(name: 'activity_id', in: 'path', description: 'Import activity ID.', required: true, schema: new OA\Schema(type: 'integer')),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Import status', content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportStatus')),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ ]
+ )]
+ public function show_import(Request $request): JsonResponse
+ {
+ return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->showDatabaseImport($database, $teamId, (int) $request->route('activity_id')));
+ }
+
+ private function withImportDatabase(Request $request, callable $callback): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $service = $this->resolveService($request, $teamId);
+ $database = $service ? $this->resolveServiceDatabase($request, $service) : null;
+
+ return $database ? $callback($database, $teamId) : response()->json(['message' => 'Service database not found.'], 404);
+ }
+
private function removeSensitiveData(ServiceDatabase $serviceDatabase): array
{
$serviceDatabase->makeHidden([
diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php
index 109f8915a1..a21850c9bf 100644
--- a/app/Http/Controllers/OauthController.php
+++ b/app/Http/Controllers/OauthController.php
@@ -2,41 +2,27 @@
namespace App\Http\Controllers;
-use App\Models\User;
-use Illuminate\Support\Facades\Auth;
+use App\Models\OauthSetting;
+use App\Services\Auth\OauthLoginService;
+use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
class OauthController extends Controller
{
public function redirect(string $provider)
{
- $socialite_provider = get_socialite_provider($provider);
+ $oauthSetting = $this->enabledProvider($provider);
+ $socialiteProvider = get_socialite_provider($oauthSetting->provider);
- return $socialite_provider->redirect();
+ return $socialiteProvider->redirect();
}
- public function callback(string $provider)
+ public function callback(string $provider, OauthLoginService $oauthLoginService)
{
try {
- $oauthUser = get_socialite_provider($provider)->user();
- $email = trim((string) $oauthUser->email);
- if ($email === '') {
- abort(403, 'OAuth provider did not return an email address');
- }
- $email = strtolower($email);
- $user = User::whereEmail($email)->first();
- if (! $user) {
- $settings = instanceSettings();
- if (! $settings->is_registration_enabled) {
- abort(403, 'Registration is disabled');
- }
-
- $user = User::create([
- 'name' => $oauthUser->name,
- 'email' => $email,
- ]);
- }
- Auth::login($user);
+ $oauthSetting = $this->enabledProvider($provider);
+ $oauthUser = get_socialite_provider($oauthSetting->provider)->user();
+ $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting);
$team = $user->resolveStoredTeam();
if (! $team && $user->teams()->count() === 0) {
@@ -48,9 +34,36 @@ class OauthController extends Controller
return redirect('/');
} catch (\Exception $e) {
+ $this->logCallbackFailure($provider, $e);
+
$errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback';
return redirect()->route('login')->withErrors([__($errorCode)]);
}
}
+
+ private function logCallbackFailure(string $provider, \Throwable $exception): void
+ {
+ Log::error('OAuth callback failed.', [
+ 'provider' => $provider,
+ 'exception_class' => $exception::class,
+ 'exception_message' => $exception->getMessage(),
+ 'request_error' => request()->query('error'),
+ 'request_error_description' => request()->query('error_description'),
+ 'has_code' => request()->query->has('code'),
+ 'has_state' => request()->query->has('state'),
+ 'ip' => request()->ip(),
+ 'exception' => $exception,
+ ]);
+ }
+
+ private function enabledProvider(string $provider): OauthSetting
+ {
+ $oauthSetting = OauthSetting::where('provider', $provider)->first();
+ if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) {
+ throw new HttpException(403, 'OAuth provider is not enabled');
+ }
+
+ return $oauthSetting;
+ }
}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index aca4293919..b1cb8d853d 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -29,6 +29,7 @@ use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
+use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
@@ -59,6 +60,7 @@ class Kernel extends HttpKernel
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
+ InvokeDeferredCallbacks::class,
];
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 6d42398c21..a980f36221 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -19,6 +19,7 @@ use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
+use App\Support\RemoteSecretReferences;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\ExecuteRemoteCommand;
@@ -147,6 +148,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_args;
+ /** @var array|null */
+ private ?array $remote_secrets_cache = null;
+
private $env_nixpacks_args;
private $env_railpack_args;
@@ -269,7 +273,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
- $this->container_name = $this->resolveContainerName();
+ $this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id);
$this->saved_outputs = collect();
@@ -335,7 +339,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($containerName === 'coolify-proxy') {
continue;
}
- if (preg_match('/-(\d{12})/', $containerName)) {
+ if (isGeneratedContainerName($containerName)) {
continue;
}
$containerIp = data_get($container, 'IPv4Address');
@@ -1309,6 +1313,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return true;
}
+ if ($this->has_remote_buildtime_secret_references()) {
+ $this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.');
+
+ return false;
+ }
$configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
if (! $configurationDiff->requiresBuild()) {
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
@@ -1336,6 +1345,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return false;
}
+ private function has_remote_buildtime_secret_references(): bool
+ {
+ $environmentVariables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()
+ : $this->application->environment_variables_preview();
+
+ return $environmentVariables
+ ->where('is_buildtime', true)
+ ->get(['value'])
+ ->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value));
+ }
+
private function check_image_locally_or_remotely()
{
$this->execute_remote_command([
@@ -1357,6 +1378,101 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ /**
+ * Fetch the secrets from the application's secret manager source. Values
+ * live only in memory during the deployment and in the generated .env on
+ * the server β they are never persisted in the Coolify database. Fetched
+ * lazily (only when a variable references a secret), once per deployment.
+ * A fetch failure fails the deployment.
+ *
+ * @return array
+ */
+ private function remote_secrets(): array
+ {
+ if ($this->remote_secrets_cache !== null) {
+ return $this->remote_secrets_cache;
+ }
+
+ $link = $this->application->secretManagerLink()->with('integrationToken')->first();
+
+ if (! $link) {
+ throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.');
+ }
+
+ $provider = $link->integrationToken->providerName();
+ $tokenName = $link->integrationToken->name;
+
+ try {
+ $secrets = $link->fetchSecrets();
+ } catch (Throwable $e) {
+ $this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr');
+
+ throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets.");
+ }
+
+ $this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}).");
+
+ return $this->remote_secrets_cache = $secrets;
+ }
+
+ /**
+ * Replace {{vault.KEY}} references with values from the configured secret
+ * manager source. Missing keys fail the deployment with a
+ * list β changing the source never re-checks references, so this is the
+ * moment problems surface.
+ */
+ private function substitute_remote_secrets(string $value, string $envKey): string
+ {
+ $secrets = $this->remote_secrets();
+ $missing = RemoteSecretReferences::missingKeys($value, $secrets);
+
+ if ($missing !== []) {
+ $message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey}).";
+ $this->application_deployment_queue->addLogEntry($message, 'stderr');
+
+ throw new DeploymentException($message.' Check the secret manager source of this application.');
+ }
+
+ return RemoteSecretReferences::substitute($value, $secrets);
+ }
+
+ /**
+ * Resolve shared variables, then secret references, in a raw variable value.
+ */
+ private function resolve_environment_variable_raw(EnvironmentVariable $env): string
+ {
+ $value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer);
+
+ return $this->substitute_remote_secrets($value ?? '', $env->key);
+ }
+
+ /**
+ * Resolve a runtime variable to its dotenv representation. Values with
+ * secret references are substituted and written as literals.
+ */
+ private function resolve_environment_variable(EnvironmentVariable $env): ?string
+ {
+ if (! RemoteSecretReferences::containsReference($env->value)) {
+ return $env->getResolvedValueWithServer($this->mainServer);
+ }
+
+ return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env));
+ }
+
+ /**
+ * Format a remote secret value for the runtime .env file (dotenv syntax read
+ * by docker compose). Values are treated as literals β no interpolation.
+ */
+ private function format_remote_secret_value(string $value): string
+ {
+ if (! str_contains($value, "'")) {
+ return "'".$value."'";
+ }
+
+ // Fall back to double quotes; $$ escapes compose interpolation.
+ return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"';
+ }
+
private function generate_runtime_environment_variables()
{
$envs = collect([]);
@@ -1425,7 +1541,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables as $env) {
- $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
+ $envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
// Check for PORT environment variable mismatch with ports_exposes
@@ -1492,7 +1608,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables_preview as $env) {
- $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
+ $envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
// Fall back to production env vars for keys not overridden by preview vars,
@@ -1506,7 +1622,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
- $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
+ $envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
}
@@ -1614,6 +1730,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
+ 'skip_command_log' => true,
]
);
@@ -1632,6 +1749,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
+ 'skip_command_log' => true,
]
);
$this->server = $this->build_server;
@@ -1639,6 +1757,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
+ 'skip_command_log' => true,
]
);
}
@@ -1765,6 +1884,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
+ if (RemoteSecretReferences::containsReference($env->value)) {
+ $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
+
+ continue;
+ }
+
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1820,6 +1945,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
+ if (RemoteSecretReferences::containsReference($env->value)) {
+ $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
+
+ continue;
+ }
+
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1879,6 +2010,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string
{
+
try {
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
throw new \InvalidArgumentException('Invalid build-time environment variable key.');
@@ -2072,7 +2204,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->write_deployment_configurations();
$this->server = $this->mainServer;
}
- if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
+ if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if (count($this->application->ports_mappings_array) > 0) {
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
@@ -2080,7 +2212,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
}
- if (str($this->application->settings->custom_internal_name)->isNotEmpty()) {
+ if ((bool) $this->application->settings->is_consistent_container_name_enabled && str($this->application->settings->custom_internal_name)->isNotEmpty()) {
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
}
if ($this->pull_request_id !== 0) {
@@ -2106,19 +2238,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
- 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 {
@@ -2789,6 +2908,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
{
+ if (RemoteSecretReferences::containsReference($environmentVariable->value)) {
+ $resolved = $this->resolve_environment_variable_raw($environmentVariable);
+
+ return $resolved === '' ? null : $resolved;
+ }
+
$resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
if (is_null($resolvedValue) || $resolvedValue === '') {
return null;
@@ -3332,7 +3457,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
- $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
+ $resolvedValue = RemoteSecretReferences::containsReference($env->value)
+ ? $this->resolve_environment_variable_raw($env)
+ : $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
@@ -3348,7 +3475,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
- $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
+ $resolvedValue = RemoteSecretReferences::containsReference($env->value)
+ ? $this->resolve_environment_variable_raw($env)
+ : $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
@@ -4169,7 +4298,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
try {
$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()) {
+ if ($this->application->settings->is_consistent_container_name_enabled) {
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
$this->containerNamesToRemove($containers)->each(function (string $containerName) {
$this->graceful_shutdown_container($containerName);
@@ -4442,7 +4571,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
$secrets_string = $variables
->map(function ($env) {
- return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}";
+ return "{$env->key}={$this->resolve_environment_variable($env)}";
})
->sort()
->implode('|');
@@ -4508,7 +4637,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
- $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
+ $argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
}
}
// Add Coolify variables as ARGs
@@ -4530,7 +4659,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
- $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
+ $argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
}
}
// Add Coolify variables as ARGs
@@ -4544,6 +4673,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
+ if ($argsToInsert->isNotEmpty()) {
+ $environmentVariables = $envs->mapWithKeys(function ($environmentVariable) {
+ return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))];
+ });
+ $secretsHash = $this->generate_secrets_hash($environmentVariables);
+ $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}");
+ }
+
// Development logging to show what ARGs are being injected
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
@@ -4565,11 +4702,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
- $envs_mapped = $envs->mapWithKeys(function ($env) {
- return [$env->key => $env->getResolvedValueWithServer($this->mainServer)];
- });
- $secrets_hash = $this->generate_secrets_hash($envs_mapped);
- $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
@@ -4578,11 +4710,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
[
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
- 'hidden' => true,
- 'ignore_errors' => true,
+ 'skip_command_log' => true,
]);
}
diff --git a/app/Jobs/ConfigureDnsRecordJob.php b/app/Jobs/ConfigureDnsRecordJob.php
new file mode 100644
index 0000000000..4e0ea64718
--- /dev/null
+++ b/app/Jobs/ConfigureDnsRecordJob.php
@@ -0,0 +1,87 @@
+with('integrationToken')
+ ->whereKey($this->zoneId)
+ ->whereHas('integrationToken', fn ($query) => $query->where('team_id', $this->teamId))
+ ->firstOrFail();
+
+ try {
+ $provider->createRecord($zone, $this->hostname, $this->content, $this->resource());
+
+ DnsRecordConfigurationFinished::dispatch(
+ $this->teamId,
+ $this->resourceType,
+ $this->resourceId,
+ $this->hostname,
+ true,
+ $zone->integrationToken->name,
+ "DNS record added for {$this->hostname}.",
+ );
+ } catch (Throwable $exception) {
+ DnsRecordConfigurationFinished::dispatch(
+ $this->teamId,
+ $this->resourceType,
+ $this->resourceId,
+ $this->hostname,
+ false,
+ $zone->integrationToken->name,
+ $exception->getMessage(),
+ );
+ }
+ }
+
+ public function failed(?Throwable $exception): void
+ {
+ DnsRecordConfigurationFinished::dispatch(
+ $this->teamId,
+ $this->resourceType,
+ $this->resourceId,
+ $this->hostname,
+ false,
+ '',
+ 'The DNS zone is no longer available.',
+ );
+ }
+
+ private function resource(): ?Model
+ {
+ $resourceClass = $this->resourceType === null ? null : (Relation::getMorphedModel($this->resourceType) ?? $this->resourceType);
+ if ($resourceClass === null || $this->resourceId === null || ! is_subclass_of($resourceClass, Model::class)) {
+ return null;
+ }
+
+ return $resourceClass::query()->find($this->resourceId);
+ }
+}
diff --git a/app/Jobs/DatabaseStartJob.php b/app/Jobs/DatabaseStartJob.php
new file mode 100644
index 0000000000..e21ee38c61
--- /dev/null
+++ b/app/Jobs/DatabaseStartJob.php
@@ -0,0 +1,88 @@
+onQueue(deployment_queue());
+ }
+
+ public function handle(): void
+ {
+ $database = $this->databaseClass::query()->findOrFail($this->databaseId);
+ abort_unless((int) $database->team()->id === $this->teamId, 403);
+ $activity = Activity::query()->findOrFail($this->activityId);
+
+ match ($database->getMorphClass()) {
+ StandalonePostgresql::class => StartPostgresql::run($database, $activity),
+ StandaloneRedis::class => StartRedis::run($database, $activity),
+ StandaloneMongodb::class => StartMongodb::run($database, $activity),
+ StandaloneMysql::class => StartMysql::run($database, $activity),
+ StandaloneMariadb::class => StartMariadb::run($database, $activity),
+ StandaloneKeydb::class => StartKeydb::run($database, $activity),
+ StandaloneDragonfly::class => StartDragonfly::run($database, $activity),
+ StandaloneClickhouse::class => StartClickhouse::run($database, $activity),
+ };
+
+ event(new DatabaseStatusChanged($this->userId));
+ }
+
+ public function failed(?Throwable $exception): void
+ {
+ try {
+ $activity = Activity::query()->find($this->activityId);
+ if (! $activity) {
+ return;
+ }
+
+ $activity->properties = $activity->properties->merge([
+ 'status' => ProcessStatus::ERROR->value,
+ 'error' => 'Database start failed.',
+ 'failed_at' => now()->toIso8601String(),
+ ]);
+ $activity->save();
+ } finally {
+ event(new DatabaseStatusChanged($this->userId));
+ }
+ }
+}
diff --git a/app/Listeners/CleanupDatabaseImport.php b/app/Listeners/CleanupDatabaseImport.php
new file mode 100644
index 0000000000..a3a36b8342
--- /dev/null
+++ b/app/Listeners/CleanupDatabaseImport.php
@@ -0,0 +1,67 @@
+ */
+ public array $backoff = [5, 15, 30];
+
+ public function handle(DatabaseImportFinished $event): void
+ {
+ $commands = $this->commands($event->data);
+ $server = Server::query()->find($event->data['serverId'] ?? null);
+
+ if ($server && $commands !== []) {
+ instant_remote_process($commands, $server);
+ }
+ }
+
+ /**
+ * @param array $data
+ * @return list
+ */
+ public function commands(array $data): array
+ {
+ $commands = [];
+
+ if (filled($data['containerName'] ?? null)) {
+ $commands[] = 'docker rm -f '.escapeshellarg($data['containerName']).' 2>/dev/null || true';
+ }
+
+ if (isSafeTmpPath($data['serverTmpPath'] ?? null)) {
+ $commands[] = 'rm -f '.escapeshellarg($data['serverTmpPath']).' 2>/dev/null || true';
+ }
+
+ if (isSafeTmpPath($data['credentialTmpPath'] ?? null)) {
+ $commands[] = 'rm -f '.escapeshellarg($data['credentialTmpPath']).' 2>/dev/null || true';
+ }
+
+ if (filled($data['container'] ?? null)) {
+ foreach (['containerTmpPath', 'scriptPath'] as $key) {
+ if (isSafeTmpPath($data[$key] ?? null)) {
+ $commands[] = 'docker exec '.escapeshellarg($data['container']).' rm -f '.escapeshellarg($data[$key]).' 2>/dev/null || true';
+ }
+ }
+ }
+
+ return $commands;
+ }
+
+ public function failed(DatabaseImportFinished $event, Throwable $exception): void
+ {
+ Log::error('Database import cleanup failed', [
+ 'serverId' => $event->data['serverId'] ?? null,
+ 'containerName' => $event->data['containerName'] ?? null,
+ 'error' => $exception->getMessage(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php
new file mode 100644
index 0000000000..abf9d88cfd
--- /dev/null
+++ b/app/Livewire/Analytics.php
@@ -0,0 +1,603 @@
+ uuid => name, for the server filter */
+ public array $serverOptions = [];
+
+ /** @var array uuid => name, for the application filter (scoped to the selected server) */
+ public array $appOptions = [];
+
+ /**
+ * Listbox options for the application filter, grouped under project headers so
+ * it's clear which application belongs to which project.
+ *
+ * @var array
+ */
+ public array $appGroupedOptions = [];
+
+ #[Url(as: 'range')]
+ public string $range = '24h';
+
+ #[Url(as: 'server')]
+ public string $serverUuid = '';
+
+ #[Url(as: 'app')]
+ public string $appUuid = '';
+
+ // Realtime refresh; off by default (click "Live" to arm it). Only meaningful on the
+ // 24h range, which matches the 60s Sentinel cache TTL.
+ public bool $live = false;
+
+ public ?array $overview = null;
+
+ public bool $latencyApproximate = false;
+
+ public bool $uniquesApproximate = false;
+
+ /** @var array> */
+ public array $topApps = [];
+
+ /** @var array> */
+ public array $topHosts = [];
+
+ /** @var array> */
+ public array $topPaths = [];
+
+ /** @var array>> */
+ public array $breakdowns = [];
+
+ public ?string $attribution = null;
+
+ /**
+ * Per-bucket status-class time series for the stacked area chart, summed across
+ * target servers and sorted by bucket. Empty when no target Sentinel exposes the
+ * series endpoint (older builds), which flips the chart back to the status donut.
+ *
+ * @var array
+ */
+ public array $series = [];
+
+ public bool $hasSeries = false;
+
+ /**
+ * Servers that could run traffic analytics but have it off β drives the nudge banner.
+ *
+ * @var array
+ */
+ public array $eligibleDisabledServers = [];
+
+ public string $nudgeKey = '';
+
+ /**
+ * Upper bound on per-app overviews fetched for the leaderboard, so a server with a huge
+ * number of recorded apps can't reintroduce a per-app round-trip storm. Truncation is
+ * logged (see loadData) rather than silently swallowed.
+ */
+ private const MAX_LEADERBOARD_APPS = 200;
+
+ /** @var array */
+ protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent'];
+
+ /**
+ * Per-request cache of app uuid => display metadata, so resolving a name/domain/link
+ * for the leaderboard and path domains hits the DB at most once per app.
+ *
+ * @var array
+ */
+ protected array $appMetaCache = [];
+
+ public function mount(?string $scopedServerUuid = null): void
+ {
+ $allServers = Server::ownedByCurrentTeamCached();
+
+ $this->scopedServerUuid = $scopedServerUuid;
+
+ if ($this->scopedServerUuid !== null) {
+ $server = $allServers->firstWhere('uuid', $this->scopedServerUuid);
+ abort_if($server === null, 404);
+
+ $this->serverUuid = $server->uuid;
+ $this->chartId = 'server-analytics-'.$server->uuid;
+ $this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect();
+ $this->serverOptions = [$server->uuid => $server->name];
+ $this->eligibleDisabledServers = [];
+ $this->nudgeKey = '';
+ } else {
+ $this->servers = $allServers
+ ->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled())
+ ->values();
+
+ $this->serverOptions = $this->servers
+ ->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name])
+ ->all();
+
+ $eligibleDisabled = $allServers
+ ->filter(fn (Server $server) => ! $server->isTrafficAnalyticsEnabled()
+ && ! $server->isSwarm()
+ && ! $server->isBuildServer())
+ ->values();
+
+ $this->eligibleDisabledServers = $eligibleDisabled
+ ->map(fn (Server $server) => ['uuid' => $server->uuid, 'name' => $server->name])
+ ->all();
+ $this->nudgeKey = substr(md5($eligibleDisabled->pluck('uuid')->sort()->implode(',')), 0, 12);
+
+ // A bookmarked ?server= may point at a server that is no longer enabled.
+ if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) {
+ $this->serverUuid = '';
+ }
+ }
+
+ $this->refreshAppOptions();
+
+ if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) {
+ $this->appUuid = '';
+ }
+
+ if ($this->servers->isNotEmpty()) {
+ $this->loadData();
+ }
+ }
+
+ public function setRange(string $range): void
+ {
+ $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
+ $this->loadData();
+ }
+
+ public function toggleLive(): void
+ {
+ if ($this->range !== '24h') {
+ return;
+ }
+ $this->live = ! $this->live;
+ }
+
+ #[On('trafficAnalyticsStateChanged')]
+ public function refreshTrafficAnalyticsState(): void
+ {
+ if ($this->scopedServerUuid === null) {
+ return;
+ }
+
+ $server = Server::ownedByCurrentTeam()->whereUuid($this->scopedServerUuid)->firstOrFail();
+ $this->overview = null;
+ $this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect();
+
+ if ($this->servers->isNotEmpty()) {
+ $this->refreshAppOptions();
+ $this->loadData();
+ }
+ }
+
+ public function isLivePollable(): bool
+ {
+ return $this->live && $this->range === '24h';
+ }
+
+ public function updatedServerUuid(): void
+ {
+ // Scope the app options to the newly selected server and drop an app filter
+ // that no longer belongs to it.
+ $this->refreshAppOptions();
+
+ if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) {
+ $this->appUuid = '';
+ }
+
+ $this->loadData();
+ }
+
+ public function updatedAppUuid(): void
+ {
+ $this->loadData();
+ }
+
+ protected function refreshAppOptions(): void
+ {
+ $enabledUuids = $this->servers->pluck('uuid');
+
+ $apps = Application::ownedByCurrentTeam()->with(['environment.project', 'destination.server'])->get()
+ ->filter(function (Application $app) use ($enabledUuids): bool {
+ $serverUuid = $app->destination?->server?->uuid;
+
+ if (! $serverUuid || ! $enabledUuids->contains($serverUuid)) {
+ return false;
+ }
+
+ return $this->serverUuid === '' || $serverUuid === $this->serverUuid;
+ });
+
+ // Flat uuid => name map, used to validate a bookmarked ?app= filter.
+ $options = $apps->mapWithKeys(fn (Application $app) => [$app->uuid => $app->name])->all();
+ asort($options);
+ $this->appOptions = $options;
+
+ // Grouped listbox options: a header row per project, then its apps (both alpha-sorted).
+ $grouped = [];
+ $byProject = $apps
+ ->groupBy(fn (Application $app) => (string) (data_get($app, 'environment.project.name') ?: 'Ungrouped'))
+ ->sortKeys();
+
+ foreach ($byProject as $projectName => $projectApps) {
+ $grouped[] = ['value' => '__group_'.md5($projectName), 'label' => $projectName, 'header' => true];
+ foreach ($projectApps->sortBy('name') as $app) {
+ $grouped[] = ['value' => $app->uuid, 'label' => $app->name];
+ }
+ }
+
+ $this->appGroupedOptions = $grouped;
+ }
+
+ /**
+ * Servers this view should query, honoring the active server/app filters.
+ */
+ protected function targetServers(): Collection
+ {
+ if ($this->scopedServerUuid !== null) {
+ return $this->servers;
+ }
+
+ if ($this->appUuid !== '') {
+ $server = Application::ownedByCurrentTeam()->whereUuid($this->appUuid)->first()
+ ?->destination?->server;
+
+ return $server && $this->servers->contains(fn (Server $s) => $s->uuid === $server->uuid)
+ ? collect([$server])
+ : collect();
+ }
+
+ if ($this->serverUuid !== '') {
+ return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values();
+ }
+
+ return $this->servers;
+ }
+
+ public function loadData(): void
+ {
+ if ($this->servers->isEmpty()) {
+ return;
+ }
+
+ [$from, $to] = $this->window();
+ $appKey = $this->appUuid !== '' ? $this->appUuid : null;
+ $servers = $this->targetServers();
+
+ $overviews = [];
+ $appRows = [];
+ $pathTotals = [];
+ $breakdownTotals = array_fill_keys($this->breakdownDimensions, []);
+ $seriesByBucket = [];
+ $attribution = null;
+
+ foreach ($servers as $server) {
+ try {
+ $client = $this->trafficClient($server);
+
+ // Warm every server-wide endpoint in one docker exec instead of ~15 serial
+ // SSH round-trips; the per-call methods below then read from cache.
+ $leaderboardUuids = $client->prefetchServerWide($appKey, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS);
+
+ // Per-application leaderboard only makes sense when not already filtered to one app.
+ if ($appKey === null && $leaderboardUuids !== []) {
+ if (count($leaderboardUuids) > self::MAX_LEADERBOARD_APPS) {
+ Log::warning('Traffic analytics leaderboard truncated', [
+ 'server' => $server->uuid,
+ 'total' => count($leaderboardUuids),
+ 'shown' => self::MAX_LEADERBOARD_APPS,
+ ]);
+ $leaderboardUuids = array_slice($leaderboardUuids, 0, self::MAX_LEADERBOARD_APPS);
+ }
+ // Warm the leaderboard's per-app overviews in a second batched exec.
+ $client->prefetchAppOverviews($leaderboardUuids, $from, $to);
+ }
+
+ $overviews[] = $client->overview($appKey, $from, $to);
+
+ if ($appKey === null) {
+ foreach ($leaderboardUuids as $uuid) {
+ $appOverview = $client->overview($uuid, $from, $to)->toArray();
+ $meta = $this->appMeta($uuid);
+
+ $appRows[] = [
+ 'uuid' => $uuid,
+ 'name' => $meta['name'],
+ 'domain' => $meta['domain'],
+ 'link' => $meta['link'],
+ 'requests' => (int) ($appOverview['requests'] ?? 0),
+ 'bandwidth' => (int) ($appOverview['bytesIn'] ?? 0) + (int) ($appOverview['bytesOut'] ?? 0),
+ ];
+ }
+ }
+
+ foreach ($client->paths($appKey, $from, $to, 50) as $path) {
+ $data = $path->toArray();
+ $pathStr = (string) ($data['path'] ?? '');
+ // Prefer the per-path app from Sentinel; fall back to the active app filter
+ // (older Sentinel omits `app`, but a filtered view still knows the app).
+ $appId = (string) ($data['app'] ?? '');
+ $resolveId = $appId !== '' ? $appId : ($appKey ?? '');
+ // Key by (app, path) so the same path under two apps stays two rows, each
+ // carrying its own domain.
+ $key = $resolveId."\n".$pathStr;
+ $domain = $resolveId !== '' ? ($this->appMeta($resolveId)['domain'] ?? null) : null;
+
+ $pathTotals[$key] ??= ['path' => $pathStr, 'domain' => $domain, 'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0];
+ $pathTotals[$key]['requests'] += (int) ($data['requests'] ?? 0);
+ $pathTotals[$key]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
+ $pathTotals[$key]['s4xx'] += (int) ($data['s4xx'] ?? 0);
+ $pathTotals[$key]['s5xx'] += (int) ($data['s5xx'] ?? 0);
+ $pathTotals[$key]['p95'] = max($pathTotals[$key]['p95'], (float) ($data['p95'] ?? 0));
+ }
+
+ foreach ($this->breakdownDimensions as $dimension) {
+ foreach ($client->breakdown($appKey, $dimension, $from, $to, 50) as $row) {
+ $data = $row->toArray();
+ $value = (string) ($data['value'] ?? '');
+
+ $breakdownTotals[$dimension][$value] ??= ['value' => $value, 'requests' => 0, 'bytesOut' => 0];
+ $breakdownTotals[$dimension][$value]['requests'] += (int) ($data['requests'] ?? 0);
+ $breakdownTotals[$dimension][$value]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
+ }
+ }
+
+ $attribution ??= $client->attribution();
+
+ // Per-bucket status series; summed by bucket across servers. Isolated so a
+ // series hiccup (or an older Sentinel lacking the endpoint) never discards a
+ // server's other data β an empty result simply flips the chart to the donut.
+ try {
+ foreach ($client->series($appKey, $this->range) as $bucket) {
+ $data = $bucket->toArray();
+ $ts = (int) ($data['bucket'] ?? 0);
+
+ $seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0];
+ $seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0);
+ $seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0);
+ $seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0);
+ $seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0);
+ $seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0);
+ $seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0);
+ $seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
+ // Uniques summed across servers (approximate); p95 takes the worst bucket.
+ $seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0);
+ $seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0));
+ }
+ } catch (\Throwable $e) {
+ // Leave this server out of the series; donut fallback covers it.
+ }
+ } catch (\Throwable $e) {
+ // Skip unreachable/failed servers so one bad server doesn't break the whole view.
+ continue;
+ }
+ }
+
+ if (empty($overviews)) {
+ $this->resetData();
+ // The chart lives under wire:ignore, so it only updates via this event β dispatch
+ // even when cleared so a previously-populated chart flips to its no-data state
+ // instead of keeping stale data.
+ $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
+
+ return;
+ }
+
+ $result = TrafficAnalyticsAggregator::sumOverviews($overviews);
+ $this->overview = $result['overview']->toArray();
+ $this->latencyApproximate = $result['latencyApproximate'];
+ $this->uniquesApproximate = $result['uniquesApproximate'];
+
+ usort($appRows, fn ($a, $b) => $b['requests'] <=> $a['requests']);
+ $this->topApps = array_slice($appRows, 0, 50);
+
+ // Top hosts: fold per-app volume up to the served hostname (an app's primary
+ // domain). Apps without a configured FQDN collapse into one "Unknown host" row.
+ $hostTotals = [];
+ foreach ($appRows as $row) {
+ $host = $row['domain'] ?? '';
+ $hostTotals[$host] ??= ['host' => $host, 'requests' => 0, 'bandwidth' => 0];
+ $hostTotals[$host]['requests'] += (int) $row['requests'];
+ $hostTotals[$host]['bandwidth'] += (int) $row['bandwidth'];
+ }
+ $hosts = array_values($hostTotals);
+ usort($hosts, fn ($a, $b) => $b['requests'] <=> $a['requests']);
+ $this->topHosts = array_slice($hosts, 0, 50);
+
+ $paths = array_values($pathTotals);
+ usort($paths, fn ($a, $b) => $b['requests'] <=> $a['requests']);
+ $this->topPaths = array_slice($paths, 0, 50);
+
+ $breakdowns = [];
+ foreach ($this->breakdownDimensions as $dimension) {
+ $rows = array_values($breakdownTotals[$dimension]);
+ usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']);
+ $breakdowns[$dimension] = array_slice($rows, 0, 50);
+ }
+ $this->breakdowns = $breakdowns;
+
+ $this->attribution = $attribution;
+
+ ksort($seriesByBucket);
+ $this->series = array_values($seriesByBucket);
+ $this->hasSeries = $this->series !== [];
+
+ $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
+ }
+
+ /**
+ * Payload for the status chart: the stacked-area time series when available,
+ * plus the donut totals as a fallback for older Sentinel builds.
+ *
+ * @return array
+ */
+ protected function chartPayload(): array
+ {
+ $device = $this->deviceChartData();
+ $overview = $this->overview ?? [];
+
+ return [
+ 'hasSeries' => $this->hasSeries,
+ 'range' => $this->range,
+ 'seriesData' => [
+ $overview['s2xx'] ?? 0,
+ $overview['s3xx'] ?? 0,
+ $overview['s4xx'] ?? 0,
+ $overview['s5xx'] ?? 0,
+ ],
+ 'timeSeries' => [
+ 'categories' => array_column($this->series, 'bucket'),
+ 'requests' => $this->requestsSpark(),
+ 's2xx' => array_column($this->series, 's2xx'),
+ 's3xx' => array_column($this->series, 's3xx'),
+ 's4xx' => array_column($this->series, 's4xx'),
+ 's5xx' => array_column($this->series, 's5xx'),
+ ],
+ 'requestsSpark' => $this->requestsSpark(),
+ 'sparkCategories' => array_column($this->series, 'bucket'),
+ 'errorsSpark' => $this->errorsSpark(),
+ 'bandwidthSpark' => $this->bandwidthSpark(),
+ 'uniquesSpark' => $this->uniquesSpark(),
+ 'latencySpark' => $this->latencySpark(),
+ 'geo' => $this->geoMarkers(),
+ 'deviceLabels' => $device['labels'],
+ 'deviceSeries' => $device['series'],
+ ];
+ }
+
+ protected function resetData(): void
+ {
+ $this->overview = null;
+ $this->latencyApproximate = false;
+ $this->uniquesApproximate = false;
+ $this->topApps = [];
+ $this->topHosts = [];
+ $this->topPaths = [];
+ $this->breakdowns = [];
+ $this->attribution = null;
+ $this->series = [];
+ $this->hasSeries = false;
+ }
+
+ public function errorRate(): float
+ {
+ if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
+ return 0.0;
+ }
+
+ $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
+
+ return round(($errors / $this->overview['requests']) * 100, 2);
+ }
+
+ public function bandwidthBytes(): int
+ {
+ if (! $this->overview) {
+ return 0;
+ }
+
+ return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0);
+ }
+
+ protected function trafficClient(Server $server): SentinelTrafficClient
+ {
+ return app(SentinelTrafficClient::class, ['server' => $server]);
+ }
+
+ /**
+ * Resolve an app uuid to its display name, primary domain, and analytics-page link,
+ * memoized per request. Returns the uuid as the name for apps not owned by the team
+ * so a Sentinel-reported uuid never discloses another team's application name.
+ *
+ * @return array{name: string, domain: ?string, link: ?string}
+ */
+ protected function appMeta(string $uuid): array
+ {
+ if (isset($this->appMetaCache[$uuid])) {
+ return $this->appMetaCache[$uuid];
+ }
+
+ $app = Application::ownedByCurrentTeam()->with('environment.project')->whereUuid($uuid)->first();
+
+ $domain = null;
+ if ($app) {
+ $first = collect($app->fqdns)->first();
+ $domain = $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null;
+ }
+
+ $link = null;
+ if ($app && data_get($app, 'environment.project.uuid')) {
+ $link = route('project.application.analytics', [
+ 'project_uuid' => $app->environment->project->uuid,
+ 'environment_uuid' => $app->environment->uuid,
+ 'application_uuid' => $app->uuid,
+ ]);
+ }
+
+ return $this->appMetaCache[$uuid] = [
+ 'name' => $app?->name ?? $uuid,
+ 'domain' => $domain,
+ 'link' => $link,
+ ];
+ }
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function window(): array
+ {
+ $to = now();
+ $from = match ($this->range) {
+ '7d' => now()->subDays(7),
+ '30d' => now()->subDays(30),
+ default => now()->subDay(),
+ };
+
+ return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
+ }
+
+ public function placeholder(array $params = []): View
+ {
+ $scopedServerUuid = $params['scopedServerUuid'] ?? null;
+ $hideSkeleton = false;
+
+ if (is_string($scopedServerUuid)) {
+ $server = Server::ownedByCurrentTeamCached()->firstWhere('uuid', $scopedServerUuid);
+ $hideSkeleton = $server !== null && ! $server->isTrafficAnalyticsEnabled();
+ }
+
+ // Rendered instantly; the Sentinel round-trips run in the deferred lazy-load request.
+ return view('livewire.analytics-placeholder', compact('hideSkeleton'));
+ }
+
+ public function render()
+ {
+ return view('livewire.analytics');
+ }
+}
diff --git a/app/Livewire/Concerns/BuildsTrafficChartPayload.php b/app/Livewire/Concerns/BuildsTrafficChartPayload.php
new file mode 100644
index 0000000000..66af46efa5
--- /dev/null
+++ b/app/Livewire/Concerns/BuildsTrafficChartPayload.php
@@ -0,0 +1,125 @@
+
+ */
+ public function requestsSpark(): array
+ {
+ return array_map(
+ fn ($b) => (int) ($b['s2xx'] ?? 0) + (int) ($b['s3xx'] ?? 0) + (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0),
+ $this->series,
+ );
+ }
+
+ /**
+ * Whether there is plottable request-over-time data for the Requests chart. False when
+ * Sentinel returned no series buckets (older builds) or every bucket is empty (no traffic
+ * in the range), so the views can render a no-data state instead of a blank chart.
+ */
+ public function hasRequestSeries(): bool
+ {
+ return array_sum($this->requestsSpark()) > 0;
+ }
+
+ /**
+ * Per-bucket error requests (4xx + 5xx), for the Error-rate spark.
+ *
+ * @return array
+ */
+ public function errorsSpark(): array
+ {
+ return array_map(
+ fn ($b) => (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0),
+ $this->series,
+ );
+ }
+
+ /**
+ * Per-bucket bandwidth (bytes in + out), for the Bandwidth spark. Empty for
+ * older Sentinel builds that don't emit per-bucket byte counts.
+ *
+ * @return array
+ */
+ public function bandwidthSpark(): array
+ {
+ return array_map(
+ fn ($b) => (int) ($b['bytesIn'] ?? 0) + (int) ($b['bytesOut'] ?? 0),
+ $this->series,
+ );
+ }
+
+ /**
+ * Per-bucket unique visitors, for the Visitors spark.
+ *
+ * @return array
+ */
+ public function uniquesSpark(): array
+ {
+ return array_map(fn ($b) => (int) ($b['uniqueVisitors'] ?? 0), $this->series);
+ }
+
+ /**
+ * Per-bucket p95 latency (ms), for the Latency spark.
+ *
+ * @return array
+ */
+ public function latencySpark(): array
+ {
+ return array_map(fn ($b) => round((float) ($b['p95'] ?? 0), 1), $this->series);
+ }
+
+ /**
+ * Per-country marker data for the globe: [{code, requests}] over known ISO-A2 rows.
+ *
+ * @return array
+ */
+ protected function geoMarkers(): array
+ {
+ $out = [];
+ foreach (($this->breakdowns['country'] ?? []) as $row) {
+ $code = strtoupper((string) ($row['value'] ?? ''));
+ $requests = (int) ($row['requests'] ?? 0);
+ if (preg_match('/^[A-Z]{2}$/', $code) && $requests > 0) {
+ $out[] = ['code' => $code, 'requests' => $requests];
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Device-donut data. Raw Sentinel device values are folded into friendly labels
+ * (pc β Desktop, smartphone β Mobile, β¦) and summed, then sorted by volume.
+ *
+ * @return array{labels: array, series: array}
+ */
+ public function deviceChartData(): array
+ {
+ $totals = [];
+ foreach (($this->breakdowns['device'] ?? []) as $row) {
+ $value = (string) ($row['value'] ?? '');
+ $label = $value === '__other__' ? 'Other' : deviceLabel($value);
+ $totals[$label] = ($totals[$label] ?? 0) + (int) ($row['requests'] ?? 0);
+ }
+ arsort($totals);
+
+ return [
+ 'labels' => array_keys($totals),
+ 'series' => array_map('intval', array_values($totals)),
+ ];
+ }
+}
diff --git a/app/Livewire/Concerns/InteractsWithDnsProviders.php b/app/Livewire/Concerns/InteractsWithDnsProviders.php
new file mode 100644
index 0000000000..692445c3c0
--- /dev/null
+++ b/app/Livewire/Concerns/InteractsWithDnsProviders.php
@@ -0,0 +1,261 @@
+authorizeDnsProviderChange();
+ $this->loadDnsProviderProposals();
+ if ($this->dnsProviderProposals === []) {
+ $this->dispatch('error', 'No connected DNS provider can manage the configured domains.');
+
+ return;
+ }
+ $this->showDnsProviderModal = true;
+ }
+
+ public function closeDnsProviderModal(): void
+ {
+ $this->showDnsProviderModal = false;
+ }
+
+ public function createManagedDnsRecord(string $hostname, int $zoneId, ?string $content = null): void
+ {
+ $this->authorizeDnsProviderChange();
+ $cloudflare = app(CloudflareDnsProvider::class);
+ $zone = $this->findTeamZone($zoneId);
+ $content ??= $this->serverIp;
+ if ($zone === null || blank($content) || filter_var($content, FILTER_VALIDATE_IP) === false) {
+ $this->dispatch('error', 'No connected DNS provider or public server IP is available for this domain.');
+
+ return;
+ }
+ try {
+ $cloudflare->createRecord($zone, $hostname, $content, $this->dnsResourceForHostname($hostname));
+ $this->markDnsManaged($hostname, $zone->integrationToken->name);
+ $this->dispatch('success', "DNS record created for {$hostname}.");
+ $this->loadDnsProviderProposals();
+ } catch (DnsRecordConflictException $e) {
+ $this->dnsProviderConflicts[$hostname.'|'.$zoneId] = [
+ 'record_id' => $e->providerRecordId, 'current' => $e->currentValue, 'proposed' => $e->proposedValue,
+ ];
+ } catch (\Throwable $e) {
+ $this->dispatch('error', $e->getMessage());
+ }
+ }
+
+ /** @param array $urls */
+ protected function hasDnsProviderForUrls(array $urls): bool
+ {
+ $provider = app(CloudflareDnsProvider::class);
+
+ return collect($urls)->contains(function (string $url) use ($provider): bool {
+ $hostname = parse_url($url, PHP_URL_HOST);
+
+ return is_string($hostname) && $provider->findZones(currentTeam()->id, $hostname)->isNotEmpty();
+ });
+ }
+
+ /** @param array $urls */
+ protected function configureDnsAfterDomainAdd(array $urls): bool
+ {
+ $hostnames = collect($urls)->map(fn (string $url) => parse_url($url, PHP_URL_HOST))
+ ->filter(fn ($hostname) => is_string($hostname))->map(fn (string $hostname) => strtolower($hostname))
+ ->unique()->values()->all();
+ $this->loadDnsProviderProposals($hostnames);
+ if ($this->dnsProviderProposals === []) {
+ return false;
+ }
+ if (blank($this->serverIp) || filter_var($this->serverIp, FILTER_VALIDATE_IP) === false) {
+ return false;
+ }
+ $this->markDnsPending($hostnames);
+
+ $proposalsByHostname = collect($this->dnsProviderProposals)->groupBy('hostname');
+ $canConfigureAutomatically = $proposalsByHostname->every(function ($proposals): bool {
+ if ($proposals->count() !== 1) {
+ return false;
+ }
+
+ $zone = $this->findTeamZone((int) $proposals->first()['zone_id']);
+
+ return $zone?->integrationToken->automaticDnsEnabled() === true;
+ });
+
+ if (! $canConfigureAutomatically) {
+ $this->showDnsProviderModal = true;
+
+ return true;
+ }
+
+ foreach ($this->dnsProviderProposals as $proposal) {
+ $zone = $this->findTeamZone((int) $proposal['zone_id']);
+ if ($zone === null) {
+ continue;
+ }
+
+ $resource = $this->dnsResourceForHostname($proposal['hostname']);
+ ConfigureDnsRecordJob::dispatch(
+ currentTeam()->id,
+ $zone->id,
+ $resource?->getMorphClass(),
+ $resource?->getKey(),
+ $proposal['hostname'],
+ $this->serverIp,
+ );
+ $this->dispatch('info', "Adding DNS record for {$proposal['hostname']}.");
+ }
+
+ return true;
+ }
+
+ public function openManualDnsRecords(): void
+ {
+ $this->authorizeDnsProviderChange();
+ $this->loadDnsProviderProposals();
+ $this->dispatch('open-dns-records-modal');
+ }
+
+ public function replaceManagedDnsRecord(string $hostname, int $zoneId, string $password = ''): void
+ {
+ $this->authorizeDnsProviderChange();
+ $key = $hostname.'|'.$zoneId;
+ $conflict = $this->dnsProviderConflicts[$key] ?? null;
+ $zone = $this->findTeamZone($zoneId);
+ $content = $this->serverIp;
+ if ($conflict === null || $zone === null || blank($content) || filter_var($content, FILTER_VALIDATE_IP) === false) {
+ $this->dispatch('error', 'The DNS conflict is no longer available. Check the record again.');
+
+ return;
+ }
+ try {
+ app(CloudflareDnsProvider::class)->replaceRecord(
+ $zone,
+ (string) ($conflict['record_id'] ?? ''),
+ $hostname,
+ $content,
+ $this->dnsResourceForHostname($hostname),
+ (string) ($conflict['current'] ?? ''),
+ );
+ unset($this->dnsProviderConflicts[$key]);
+ $this->dispatch('success', "DNS record replaced for {$hostname}.");
+ $this->loadDnsProviderProposals();
+ } catch (\Throwable $e) {
+ unset($this->dnsProviderConflicts[$key]);
+ $this->dispatch('error', $e->getMessage());
+ }
+ }
+
+ protected function loadDnsProviderProposals(?array $hostnames = null): void
+ {
+ $provider = app(CloudflareDnsProvider::class);
+ $hostnames ??= $this->allDomainHostnames();
+ $managed = ManagedDnsRecord::query()->where('team_id', currentTeam()->id)->whereIn('name', $hostnames)->pluck('id', 'name');
+ $this->dnsProviderProposals = collect($hostnames)->flatMap(fn (string $hostname) => $provider->findZones(currentTeam()->id, $hostname)
+ ->map(fn (DnsProviderZone $zone) => [
+ 'hostname' => $hostname, 'zone_id' => $zone->id, 'zone' => $zone->name,
+ 'credential' => $zone->integrationToken->name, 'target' => (string) $this->serverIp,
+ 'managed' => $managed->has($hostname),
+ ])->all())->values()->all();
+ }
+
+ protected function markDnsPending(array $hostnames): void
+ {
+ foreach ($this->domainRows as $index => $row) {
+ $hostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
+ if (is_string($hostname) && in_array(strtolower($hostname), $hostnames, true)) {
+ $this->domainRows[$index]['dns_status'] = 'pending';
+ $this->domainRows[$index]['dns_message'] = 'A connected DNS provider can create this record.';
+ $this->domainRows[$index]['checked_at'] = now()->toIso8601String();
+ }
+ }
+ $this->persistDomainDnsStatuses();
+ }
+
+ protected function markDnsManaged(string $hostname, string $credential): void
+ {
+ foreach ($this->domainRows as $index => $row) {
+ $rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
+ if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) {
+ $this->domainRows[$index]['dns_status'] = 'ok';
+ $this->domainRows[$index]['dns_message'] = "DNS record created through {$credential}.";
+ $this->domainRows[$index]['checked_at'] = now()->toIso8601String();
+ }
+ }
+ $this->persistDomainDnsStatuses();
+ }
+
+ public function dnsRecordConfigurationFinished(array $event): void
+ {
+ $resource = $this->dnsResourceForHostname($event['hostname']);
+ if ($resource === null || $resource->getMorphClass() !== $event['resourceType']
+ || (string) $resource->getKey() !== (string) $event['resourceId']) {
+ return;
+ }
+
+ if ($event['successful']) {
+ $this->markDnsManaged($event['hostname'], $event['credential']);
+ $this->dispatch('success', $event['message']);
+
+ return;
+ }
+
+ $this->dispatch('error', "DNS record could not be added for {$event['hostname']}: {$event['message']}");
+ }
+
+ protected function deleteManagedDnsForUrl(string $url): void
+ {
+ $hostname = parse_url($url, PHP_URL_HOST);
+ if (! is_string($hostname)) {
+ return;
+ }
+
+ $resource = $this->dnsResourceForHostname($hostname);
+ if ($resource === null) {
+ return;
+ }
+
+ $record = ManagedDnsRecord::query()
+ ->where('team_id', currentTeam()->id)
+ ->where('name', strtolower($hostname))
+ ->where('resource_type', $resource->getMorphClass())
+ ->where('resource_id', $resource->getKey())
+ ->first();
+
+ if ($record !== null && ! app(CloudflareDnsProvider::class)->deleteRecord($record)) {
+ $this->dispatch('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.');
+ }
+ }
+
+ protected function authorizeDnsProviderChange(): void
+ {
+ $this->authorize('update', property_exists($this, 'application') ? $this->application : $this->service);
+ }
+
+ protected function findTeamZone(int $zoneId): ?DnsProviderZone
+ {
+ return DnsProviderZone::query()->whereKey($zoneId)
+ ->whereHas('integrationToken', fn ($query) => $query->where('team_id', currentTeam()->id))->first();
+ }
+
+ abstract protected function persistDomainDnsStatuses(): void;
+
+ abstract protected function dnsResourceForHostname(string $hostname): ?Model;
+}
diff --git a/app/Livewire/Dashboard/TrafficAnalytics.php b/app/Livewire/Dashboard/TrafficAnalytics.php
new file mode 100644
index 0000000000..532460d454
--- /dev/null
+++ b/app/Livewire/Dashboard/TrafficAnalytics.php
@@ -0,0 +1,180 @@
+
+ */
+ public array $series = [];
+
+ public function mount(): void
+ {
+ $this->servers = Server::ownedByCurrentTeamCached()
+ ->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled())
+ ->values();
+
+ if ($this->servers->isNotEmpty()) {
+ $this->loadData();
+ }
+ }
+
+ public function setRange(string $range): void
+ {
+ $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
+ $this->loadData();
+ }
+
+ public function loadData(): void
+ {
+ if ($this->servers->isEmpty()) {
+ return;
+ }
+
+ [$from, $to] = $this->window();
+
+ $overviews = [];
+ $seriesByBucket = [];
+
+ foreach ($this->servers as $server) {
+ try {
+ $client = $this->trafficClient($server);
+
+ $overviews[] = $client->overview(null, $from, $to);
+
+ // Per-bucket status series, summed across servers, for the sparklines.
+ // Isolated so a series hiccup (older Sentinel) never drops a server's overview.
+ try {
+ foreach ($client->series(null, $this->range) as $bucket) {
+ $data = $bucket->toArray();
+ $ts = (int) ($data['bucket'] ?? 0);
+
+ $seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0];
+ $seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0);
+ $seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0);
+ $seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0);
+ $seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0);
+ $seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0);
+ $seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0);
+ $seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
+ $seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0);
+ $seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0));
+ }
+ } catch (\Throwable $e) {
+ // Leave this server out of the sparkline series.
+ \Log::debug('Traffic series fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]);
+ }
+ } catch (\Throwable $e) {
+ // Skip unreachable/failed servers so one bad server doesn't break the whole summary.
+ \Log::debug('Traffic overview fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]);
+
+ continue;
+ }
+ }
+
+ if (empty($overviews)) {
+ // Every server's fetch failed; don't present an all-zero KPI panel as if it were real data.
+ $this->overview = null;
+ $this->latencyApproximate = false;
+ $this->uniquesApproximate = false;
+ $this->series = [];
+
+ return;
+ }
+
+ $result = TrafficAnalyticsAggregator::sumOverviews($overviews);
+
+ $this->overview = $result['overview']->toArray();
+ $this->latencyApproximate = $result['latencyApproximate'];
+ $this->uniquesApproximate = $result['uniquesApproximate'];
+
+ ksort($seriesByBucket);
+ $this->series = array_values($seriesByBucket);
+
+ $this->dispatch("refreshChartData-{$this->chartId}-status", [
+ 'requestsSpark' => $this->requestsSpark(),
+ 'sparkCategories' => array_column($this->series, 'bucket'),
+ 'errorsSpark' => $this->errorsSpark(),
+ 'bandwidthSpark' => $this->bandwidthSpark(),
+ 'uniquesSpark' => $this->uniquesSpark(),
+ ]);
+ }
+
+ public function errorRate(): float
+ {
+ if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
+ return 0.0;
+ }
+
+ $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
+
+ return round(($errors / $this->overview['requests']) * 100, 2);
+ }
+
+ public function bandwidthBytes(): int
+ {
+ if (! $this->overview) {
+ return 0;
+ }
+
+ return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0);
+ }
+
+ protected function trafficClient(Server $server): SentinelTrafficClient
+ {
+ return app(SentinelTrafficClient::class, ['server' => $server]);
+ }
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function window(): array
+ {
+ $to = now();
+ $from = match ($this->range) {
+ '7d' => now()->subDays(7),
+ '30d' => now()->subDays(30),
+ default => now()->subDay(),
+ };
+
+ return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
+ }
+
+ public function placeholder(): View
+ {
+ // Rendered instantly on the dashboard; Sentinel round-trips run in the deferred request.
+ return view('livewire.dashboard.traffic-analytics-placeholder');
+ }
+
+ public function render()
+ {
+ return view('livewire.dashboard.traffic-analytics');
+ }
+}
diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php
index 8ea4bbc958..cb31e6c111 100644
--- a/app/Livewire/Notifications/Discord.php
+++ b/app/Livewire/Notifications/Discord.php
@@ -170,6 +170,30 @@ class Discord extends Component
}
}
+ public function toggleDiscordEnabled(): void
+ {
+ try {
+ $this->resetErrorBag();
+
+ if ($this->discordEnabled) {
+ $this->discordEnabled = false;
+ } else {
+ $this->validate([
+ 'discordWebhookUrl' => 'required',
+ ], [
+ 'discordWebhookUrl.required' => 'Discord Webhook URL is required.',
+ ]);
+ $this->discordEnabled = true;
+ }
+
+ $this->saveModel();
+ } catch (\Throwable $e) {
+ $this->syncData();
+
+ handleError($e, $this);
+ }
+ }
+
public function instantSave()
{
try {
diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php
index 5bd55137b5..ea626ed57a 100644
--- a/app/Livewire/Notifications/Email.php
+++ b/app/Livewire/Notifications/Email.php
@@ -258,32 +258,59 @@ class Email extends Component
}
}
+ public function toggleSmtp()
+ {
+ try {
+ $this->resetErrorBag();
+
+ if ($this->smtpEnabled) {
+ $this->smtpEnabled = false;
+ $this->saveModel();
+ } else {
+ $this->validateSmtpSettings();
+ $this->smtpEnabled = true;
+ $this->resendEnabled = false;
+ $this->submitSmtp();
+ }
+ } catch (\Throwable $e) {
+ $this->syncData();
+
+ return handleError($e, $this);
+ } finally {
+ $this->dispatch('refresh');
+ }
+ }
+
+ public function toggleResend()
+ {
+ try {
+ $this->resetErrorBag();
+
+ if ($this->resendEnabled) {
+ $this->resendEnabled = false;
+ $this->saveModel();
+ } else {
+ $this->validateResendSettings();
+ $this->resendEnabled = true;
+ $this->smtpEnabled = false;
+ $this->submitResend();
+ }
+ } catch (\Throwable $e) {
+ $this->syncData();
+
+ return handleError($e, $this);
+ } finally {
+ $this->dispatch('refresh');
+ }
+ }
+
public function submitSmtp()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
- $this->validate([
- 'smtpEnabled' => 'boolean',
- 'smtpFromAddress' => 'required|email',
- 'smtpFromName' => 'required|string',
- 'smtpHost' => 'required|string',
- 'smtpPort' => 'required|numeric',
- 'smtpEncryption' => 'required|string|in:starttls,tls,none',
- 'smtpUsername' => 'nullable|string',
- 'smtpPassword' => 'nullable|string',
- 'smtpTimeout' => 'nullable|numeric',
- 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
- ], [
- 'smtpFromAddress.required' => 'From Address is required.',
- 'smtpFromAddress.email' => 'Please enter a valid email address.',
- 'smtpFromName.required' => 'From Name is required.',
- 'smtpHost.required' => 'SMTP Host is required.',
- 'smtpPort.required' => 'SMTP Port is required.',
- 'smtpPort.numeric' => 'SMTP Port must be a number.',
- 'smtpEncryption.required' => 'Encryption type is required.',
- ]);
+ $this->validateSmtpSettings();
if ($this->smtpEnabled) {
$this->settings->resend_enabled = $this->resendEnabled = false;
@@ -315,17 +342,7 @@ class Email extends Component
try {
$this->resetErrorBag();
- $this->validate([
- 'resendEnabled' => 'boolean',
- 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
- 'smtpFromAddress' => 'required|email',
- 'smtpFromName' => 'required|string',
- ], [
- 'resendApiKey.required' => 'Resend API Key is required.',
- 'smtpFromAddress.required' => 'From Address is required.',
- 'smtpFromAddress.email' => 'Please enter a valid email address.',
- 'smtpFromName.required' => 'From Name is required.',
- ]);
+ $this->validateResendSettings();
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
@@ -342,6 +359,45 @@ class Email extends Component
}
}
+ private function validateSmtpSettings(): void
+ {
+ $this->validate([
+ 'smtpEnabled' => 'boolean',
+ 'smtpFromAddress' => 'required|email',
+ 'smtpFromName' => 'required|string',
+ 'smtpHost' => 'required|string',
+ 'smtpPort' => 'required|numeric',
+ 'smtpEncryption' => 'required|string|in:starttls,tls,none',
+ 'smtpUsername' => 'nullable|string',
+ 'smtpPassword' => 'nullable|string',
+ 'smtpTimeout' => 'nullable|numeric',
+ 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
+ ], [
+ 'smtpFromAddress.required' => 'From Address is required.',
+ 'smtpFromAddress.email' => 'Please enter a valid email address.',
+ 'smtpFromName.required' => 'From Name is required.',
+ 'smtpHost.required' => 'SMTP Host is required.',
+ 'smtpPort.required' => 'SMTP Port is required.',
+ 'smtpPort.numeric' => 'SMTP Port must be a number.',
+ 'smtpEncryption.required' => 'Encryption type is required.',
+ ]);
+ }
+
+ private function validateResendSettings(): void
+ {
+ $this->validate([
+ 'resendEnabled' => 'boolean',
+ 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
+ 'smtpFromAddress' => 'required|email',
+ 'smtpFromName' => 'required|string',
+ ], [
+ 'resendApiKey.required' => 'Resend API Key is required.',
+ 'smtpFromAddress.required' => 'From Address is required.',
+ 'smtpFromAddress.email' => 'Please enter a valid email address.',
+ 'smtpFromName.required' => 'From Name is required.',
+ ]);
+ }
+
public function sendTestEmail()
{
try {
diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php
index 7caacdb916..cae1c3d689 100644
--- a/app/Livewire/Notifications/Pushover.php
+++ b/app/Livewire/Notifications/Pushover.php
@@ -163,6 +163,34 @@ class Pushover extends Component
}
}
+ public function togglePushoverEnabled()
+ {
+ try {
+ $this->resetErrorBag();
+
+ if ($this->pushoverEnabled) {
+ $this->pushoverEnabled = false;
+ } else {
+ $this->validate([
+ 'pushoverUserKey' => 'required',
+ 'pushoverApiToken' => 'required',
+ ], [
+ 'pushoverUserKey.required' => 'Pushover User Key is required.',
+ 'pushoverApiToken.required' => 'Pushover API Token is required.',
+ ]);
+ $this->pushoverEnabled = true;
+ }
+
+ $this->saveModel();
+ } catch (\Throwable $e) {
+ $this->syncData();
+
+ return handleError($e, $this);
+ } finally {
+ $this->dispatch('refresh');
+ }
+ }
+
public function instantSave()
{
try {
diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php
index fe84710bdf..644252c1a3 100644
--- a/app/Livewire/Notifications/Slack.php
+++ b/app/Livewire/Notifications/Slack.php
@@ -154,6 +154,32 @@ class Slack extends Component
}
}
+ public function toggleSlackEnabled()
+ {
+ try {
+ $this->resetErrorBag();
+
+ if ($this->slackEnabled) {
+ $this->slackEnabled = false;
+ } else {
+ $this->validate([
+ 'slackWebhookUrl' => 'required',
+ ], [
+ 'slackWebhookUrl.required' => 'Slack Webhook URL is required.',
+ ]);
+ $this->slackEnabled = true;
+ }
+
+ $this->saveModel();
+ } catch (\Throwable $e) {
+ $this->syncData();
+
+ return handleError($e, $this);
+ } finally {
+ $this->dispatch('refresh');
+ }
+ }
+
public function instantSave()
{
try {
diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php
index 51239b8621..f999294477 100644
--- a/app/Livewire/Notifications/Telegram.php
+++ b/app/Livewire/Notifications/Telegram.php
@@ -263,6 +263,34 @@ class Telegram extends Component
}
}
+ public function toggleTelegramEnabled(): void
+ {
+ try {
+ $this->resetErrorBag();
+
+ if ($this->telegramEnabled) {
+ $this->telegramEnabled = false;
+ } else {
+ $this->validate([
+ 'telegramToken' => 'required',
+ 'telegramChatId' => 'required',
+ ], [
+ 'telegramToken.required' => 'Telegram Token is required.',
+ 'telegramChatId.required' => 'Telegram Chat ID is required.',
+ ]);
+ $this->telegramEnabled = true;
+ }
+
+ $this->saveModel();
+ } catch (\Throwable $e) {
+ $this->syncData();
+
+ handleError($e, $this);
+ } finally {
+ $this->dispatch('refresh');
+ }
+ }
+
public function saveModel()
{
$this->authorize('update', $this->settings);
diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php
index a3480ada69..fb537fc7d9 100644
--- a/app/Livewire/Notifications/Webhook.php
+++ b/app/Livewire/Notifications/Webhook.php
@@ -148,6 +148,30 @@ class Webhook extends Component
}
}
+ public function toggleWebhookEnabled()
+ {
+ try {
+ $this->resetErrorBag();
+
+ if ($this->webhookEnabled) {
+ $this->webhookEnabled = false;
+ } else {
+ $this->validate([
+ 'webhookUrl' => 'required',
+ ], [
+ 'webhookUrl.required' => 'Webhook URL is required.',
+ ]);
+ $this->webhookEnabled = true;
+ }
+
+ $this->saveModel();
+ } catch (\Throwable $e) {
+ $this->syncData();
+
+ return handleError($e, $this);
+ }
+ }
+
public function instantSave()
{
try {
diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php
index 04f58dadd2..69f27b0e55 100644
--- a/app/Livewire/Profile/Index.php
+++ b/app/Livewire/Profile/Index.php
@@ -2,19 +2,15 @@
namespace App\Livewire\Profile;
-use App\Services\AvatarStorageService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Validate;
use Livewire\Component;
-use Livewire\WithFileUploads;
class Index extends Component
{
- use WithFileUploads;
-
public int $userId;
public string $email;
@@ -36,6 +32,10 @@ class Index extends Component
public bool $show_verification = false;
+ public bool $uses_sso = false;
+
+ public ?string $sso_provider_label = null;
+
public $avatar;
public function uploadAvatar(AvatarStorageService $avatarStorage): bool
@@ -75,8 +75,12 @@ class Index extends Component
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
+ $oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first();
+ $this->uses_sso = $oauthIdentity !== null;
+ $this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null;
+
// Check if there's a pending email change
- if (Auth::user()->hasEmailChangeRequest()) {
+ if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) {
$this->new_email = Auth::user()->pending_email;
$this->show_verification = true;
}
@@ -101,6 +105,10 @@ class Index extends Component
public function requestEmailChange()
{
try {
+ if ($this->rejectSsoEmailChange()) {
+ return;
+ }
+
// For self-hosted, check if email is enabled
if (! isCloud()) {
$settings = instanceSettings();
@@ -159,6 +167,10 @@ class Index extends Component
public function verifyEmailChange()
{
try {
+ if ($this->rejectSsoEmailChange()) {
+ return;
+ }
+
$this->validate([
'email_verification_code' => ['required', 'string', 'size:6'],
]);
@@ -204,7 +216,6 @@ class Index extends Component
$this->show_verification = false;
$this->dispatch('success', 'Email address updated successfully.');
- $this->dispatch('close-email-change-modal');
} else {
$this->dispatch('error', 'Failed to update email address.');
}
@@ -216,6 +227,10 @@ class Index extends Component
public function resendVerificationCode()
{
try {
+ if ($this->rejectSsoEmailChange()) {
+ return;
+ }
+
// Check if there's a pending request
if (! Auth::user()->hasEmailChangeRequest()) {
$this->dispatch('error', 'No pending email change request.');
@@ -269,6 +284,30 @@ class Index extends Component
$this->dispatch('success', 'Email change request cancelled.');
}
+ public function showEmailChangeForm()
+ {
+ if ($this->rejectSsoEmailChange()) {
+ return;
+ }
+
+ $this->show_email_change = true;
+ $this->new_email = '';
+ }
+
+ private function rejectSsoEmailChange(): bool
+ {
+ if (! Auth::user()->hasSsoIdentity()) {
+ return false;
+ }
+
+ $this->uses_sso = true;
+ $this->show_email_change = false;
+ $this->show_verification = false;
+ $this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.');
+
+ return true;
+ }
+
public function resetPassword()
{
try {
@@ -299,6 +338,14 @@ class Index extends Component
}
}
+ private function providerLabel(string $provider): string
+ {
+ return match ($provider) {
+ 'oidc' => 'OIDC',
+ default => str($provider)->headline()->toString(),
+ };
+ }
+
public function render()
{
return view('livewire.profile.index');
diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php
index a9e1c0be28..a57d529bcb 100644
--- a/app/Livewire/Project/Application/Advanced.php
+++ b/app/Livewire/Project/Application/Advanced.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Models\Application;
+use App\Models\ApplicationSetting;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
@@ -69,6 +70,9 @@ class Advanced extends Component
#[Validate(['string', 'nullable'])]
public ?string $customInternalName = null;
+ #[Validate(['string', 'nullable', 'max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH])]
+ public ?string $customContainerNamePrefix = null;
+
#[Validate(['boolean'])]
public bool $isGzipEnabled = true;
@@ -111,6 +115,7 @@ class Advanced extends Component
$this->application->settings->is_build_server_enabled = $this->isBuildServerEnabled;
$this->application->settings->is_consistent_container_name_enabled = $this->isConsistentContainerNameEnabled;
$this->application->settings->custom_internal_name = $this->customInternalName;
+ $this->application->settings->custom_container_name_prefix = $this->customContainerNamePrefix;
$this->application->settings->is_gzip_enabled = $this->isGzipEnabled;
$this->application->settings->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
@@ -137,6 +142,7 @@ class Advanced extends Component
$this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
$this->isConsistentContainerNameEnabled = $this->application->settings->is_consistent_container_name_enabled;
$this->customInternalName = $this->application->settings->custom_internal_name;
+ $this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix;
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
$this->disableBuildCache = $this->application->settings->disable_build_cache;
@@ -258,6 +264,28 @@ class Advanced extends Component
}
}
+ public function saveCustomNamePrefix()
+ {
+ try {
+ $this->authorize('update', $this->application);
+
+ $this->customContainerNamePrefix = str($this->customContainerNamePrefix)->slug()->value() ?: null;
+
+ if ($this->customContainerNamePrefix && ApplicationSetting::isContainerNamePrefixInUse($this->customContainerNamePrefix, $this->application->destination->server, $this->application->id)) {
+ $this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix;
+ $this->dispatch('error', 'This container name prefix is already in use by another application on this Coolify instance.');
+
+ return;
+ }
+
+ $this->syncData(true);
+ $this->dispatch('success', 'Container name prefix saved.');
+ $this->dispatch('configurationChanged');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function saveStopGracePeriod()
{
try {
diff --git a/app/Livewire/Project/Application/Analytics.php b/app/Livewire/Project/Application/Analytics.php
new file mode 100644
index 0000000000..0c5e30b737
--- /dev/null
+++ b/app/Livewire/Project/Application/Analytics.php
@@ -0,0 +1,243 @@
+>> */
+ public array $breakdowns = [];
+
+ public ?string $attribution = null;
+
+ /**
+ * Per-bucket status-class time series for the stacked area chart. Empty when this
+ * app's Sentinel lacks the series endpoint, which flips the chart to the donut.
+ *
+ * @var array
+ */
+ public array $series = [];
+
+ public bool $hasSeries = false;
+
+ /** @var array */
+ protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent'];
+
+ public function mount(): void
+ {
+ $this->enabled = (bool) $this->application->destination?->server?->isTrafficAnalyticsEnabled();
+
+ if ($this->enabled) {
+ $this->loadData();
+ }
+ }
+
+ public function setRange(string $range): void
+ {
+ $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
+ $this->loadData();
+ }
+
+ public function toggleLive(): void
+ {
+ if ($this->range !== '24h') {
+ return;
+ }
+ $this->live = ! $this->live;
+ }
+
+ /**
+ * Realtime polling is only armed when the user has it on and the range is 24h.
+ */
+ public function isLivePollable(): bool
+ {
+ return $this->live && $this->range === '24h';
+ }
+
+ public function loadData(): void
+ {
+ if (! $this->enabled) {
+ return;
+ }
+
+ try {
+ [$from, $to] = $this->window();
+ $client = $this->trafficClient();
+ $key = $this->application->uuid;
+
+ // Warm every endpoint for this app in one docker exec instead of ~14 serial
+ // SSH round-trips; the per-call methods below then read from cache.
+ $client->prefetchServerWide($key, $from, $to, $this->breakdownDimensions, $this->range);
+
+ $this->overview = $client->overview($key, $from, $to)->toArray();
+
+ // Every path belongs to this one app, so decorate each row with its domain
+ // for a consistent "domain + path" presentation and an openable live link.
+ $domain = $this->applicationDomain();
+ $this->topPaths = $client->paths($key, $from, $to, 50)
+ ->map(fn ($path) => ['domain' => $domain] + $path->toArray())
+ ->all();
+
+ $breakdowns = [];
+ foreach ($this->breakdownDimensions as $dimension) {
+ $breakdowns[$dimension] = $client->breakdown($key, $dimension, $from, $to, 50)
+ ->map(fn ($row) => $row->toArray())
+ ->all();
+ }
+ $this->breakdowns = $breakdowns;
+
+ $this->attribution = $client->attribution();
+
+ // Per-bucket status series; absent on older Sentinel builds (empty β donut fallback).
+ // Isolated so a series hiccup never errors the rest of the widget.
+ try {
+ $this->series = $client->series($key, $this->range)
+ ->map(fn ($bucket) => $bucket->toArray())
+ ->all();
+ } catch (\Throwable $e) {
+ $this->series = [];
+ }
+ $this->hasSeries = $this->series !== [];
+
+ $this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ /**
+ * Payload for the status chart: the stacked-area time series when available,
+ * plus the donut totals as a fallback for older Sentinel builds.
+ *
+ * @return array
+ */
+ protected function chartPayload(): array
+ {
+ $device = $this->deviceChartData();
+
+ return [
+ 'hasSeries' => $this->hasSeries,
+ 'range' => $this->range,
+ 'seriesData' => [
+ $this->overview['s2xx'] ?? 0,
+ $this->overview['s3xx'] ?? 0,
+ $this->overview['s4xx'] ?? 0,
+ $this->overview['s5xx'] ?? 0,
+ ],
+ 'timeSeries' => [
+ 'categories' => array_column($this->series, 'bucket'),
+ 'requests' => $this->requestsSpark(),
+ 's2xx' => array_column($this->series, 's2xx'),
+ 's3xx' => array_column($this->series, 's3xx'),
+ 's4xx' => array_column($this->series, 's4xx'),
+ 's5xx' => array_column($this->series, 's5xx'),
+ ],
+ 'requestsSpark' => $this->requestsSpark(),
+ 'sparkCategories' => array_column($this->series, 'bucket'),
+ 'errorsSpark' => $this->errorsSpark(),
+ 'bandwidthSpark' => $this->bandwidthSpark(),
+ 'uniquesSpark' => $this->uniquesSpark(),
+ 'latencySpark' => $this->latencySpark(),
+ 'geo' => $this->geoMarkers(),
+ 'deviceLabels' => $device['labels'],
+ 'deviceSeries' => $device['series'],
+ ];
+ }
+
+ public function errorRate(): float
+ {
+ if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
+ return 0.0;
+ }
+
+ $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
+
+ return round(($errors / $this->overview['requests']) * 100, 2);
+ }
+
+ public function bandwidthBytes(): int
+ {
+ if (! $this->overview) {
+ return 0;
+ }
+
+ return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0);
+ }
+
+ protected function trafficClient(): SentinelTrafficClient
+ {
+ return app(SentinelTrafficClient::class, ['server' => $this->application->destination->server]);
+ }
+
+ /**
+ * Primary domain host for this application (first configured FQDN), or null when
+ * none is set β used to present paths as "domain + path" with an openable link.
+ */
+ protected function applicationDomain(): ?string
+ {
+ $first = collect($this->application->fqdns)->first();
+
+ return $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null;
+ }
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function window(): array
+ {
+ $to = now();
+ $from = match ($this->range) {
+ '7d' => now()->subDays(7),
+ '30d' => now()->subDays(30),
+ default => now()->subDay(),
+ };
+
+ return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
+ }
+
+ public function placeholder(array $params = []): View
+ {
+ $application = $params['application'] ?? null;
+
+ if ($application instanceof Application && ! $application->destination?->server?->isTrafficAnalyticsEnabled()) {
+ $this->application = $application;
+ $this->enabled = false;
+
+ return view('livewire.project.application.analytics');
+ }
+
+ // Rendered instantly; the Sentinel round-trip runs in the deferred lazy-load request.
+ return view('livewire.project.application.analytics-placeholder');
+ }
+
+ public function render()
+ {
+ return view('livewire.project.application.analytics');
+ }
+}
diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php
index b60f543ba5..3abc2da73c 100644
--- a/app/Livewire/Project/Application/DeploymentNavbar.php
+++ b/app/Livewire/Project/Application/DeploymentNavbar.php
@@ -104,7 +104,6 @@ class DeploymentNavbar extends Component
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
-
try {
if ($this->application->settings->is_build_server_enabled) {
$server = Server::ownedByCurrentTeam()->find($build_server_id);
diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php
index ae37e19cdb..929c02e93e 100644
--- a/app/Livewire/Project/Application/Domains.php
+++ b/app/Livewire/Project/Application/Domains.php
@@ -5,12 +5,14 @@ namespace App\Livewire\Project\Application;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
+use App\Livewire\Concerns\InteractsWithDnsProviders;
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\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -20,6 +22,7 @@ class Domains extends Component
{
use AuthorizesRequests;
use InteractsWithCloudflareDomainConnect;
+ use InteractsWithDnsProviders;
protected bool $notifyRedirectUpdate = true;
@@ -127,6 +130,13 @@ class Domains extends Component
'confirmDomainUsage',
];
+ public function getListeners(): array
+ {
+ return array_merge($this->listeners, [
+ 'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished',
+ ]);
+ }
+
protected function rules(): array
{
return [
@@ -1027,8 +1037,14 @@ class Domains extends Component
$this->resetAddDomainForm();
$this->dispatch('close-modal');
$this->refreshDomains();
- $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
- $dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck))
+ $addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
+ if ($this->configureDnsAfterDomainAdd($addedUrls)) {
+ $this->dispatch('success', 'Domain added.');
+
+ return;
+ }
+
+ $dnsChecks = collect($this->dnsEntriesForUrls($addedUrls, $serviceForCheck))
->map(fn (string $url, string $statusKey) => [
'status_key' => $statusKey,
'url' => $url,
@@ -1562,7 +1578,7 @@ class Domains extends Component
}
}
- public function removeDomain(int $index): void
+ public function removeDomain(int $index, string $password = '', array $selectedActions = []): void
{
try {
$this->authorize('update', $this->application);
@@ -1585,6 +1601,10 @@ class Domains extends Component
return;
}
+ if (in_array('deleteManagedDns', $selectedActions, true)) {
+ $this->deleteManagedDnsForUrl($url);
+ }
+
if ($this->editingIndex === $index) {
$this->cancelEdit();
}
@@ -1597,7 +1617,7 @@ class Domains extends Component
}
}
- public function removeDomainByKey(string $domainKey): void
+ public function removeDomainByKey(string $domainKey, string $password = '', array $selectedActions = []): void
{
$index = collect($this->domainRows)->search(
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
@@ -1608,7 +1628,7 @@ class Domains extends Component
return;
}
- $this->removeDomain((int) $index);
+ $this->removeDomain((int) $index, $password, $selectedActions);
}
/**
@@ -1619,6 +1639,11 @@ class Domains extends Component
return hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
}
+ protected function dnsResourceForHostname(string $hostname): ?Model
+ {
+ return $this->application;
+ }
+
public function generateDomain(?string $serviceName = null): void
{
try {
diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php
index 6c75cd7a61..830a4eace8 100644
--- a/app/Livewire/Project/Application/Heading.php
+++ b/app/Livewire/Project/Application/Heading.php
@@ -156,6 +156,11 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping application. It could take a while depending on the application.');
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
+ auditLog('ui.application.stopped', [
+ 'team_id' => $this->application->team()?->id,
+ 'application_uuid' => $this->application->uuid,
+ 'application_name' => $this->application->name,
+ ]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php
index acc7dc8608..79a393c192 100644
--- a/app/Livewire/Project/Application/Previews.php
+++ b/app/Livewire/Project/Application/Previews.php
@@ -314,6 +314,12 @@ class Previews extends Component
ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->update(['status' => 'exited']);
+ auditLog('ui.application.preview_stopped', [
+ 'team_id' => $this->application->team()?->id,
+ 'application_uuid' => $this->application->uuid,
+ 'application_name' => $this->application->name,
+ 'pull_request_id' => $pull_request_id,
+ ]);
ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
GetContainersStatus::run($server);
diff --git a/app/Livewire/Project/Application/TrafficOverview.php b/app/Livewire/Project/Application/TrafficOverview.php
new file mode 100644
index 0000000000..223c6047ae
--- /dev/null
+++ b/app/Livewire/Project/Application/TrafficOverview.php
@@ -0,0 +1,73 @@
+application->destination?->server;
+ $this->serverUuid = $server?->uuid;
+ $this->enabled = (bool) $server?->isTrafficAnalyticsEnabled();
+ $this->eligible = $server ? (! $server->isSwarm() && ! $server->isBuildServer()) : false;
+
+ if ($this->enabled && $server) {
+ try {
+ $client = app(SentinelTrafficClient::class, ['server' => $server]);
+ $this->overview = $client->appOverview($this->application->uuid, '24h')->toArray();
+ } catch (\Throwable $e) {
+ $this->overview = null;
+ }
+ }
+ }
+
+ public function hasData(): bool
+ {
+ return $this->overview !== null && (int) ($this->overview['requests'] ?? 0) > 0;
+ }
+
+ public function errorRate(): float
+ {
+ if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
+ return 0.0;
+ }
+
+ $errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
+
+ return round(($errors / $this->overview['requests']) * 100, 2);
+ }
+
+ public function placeholder(): string
+ {
+ return <<<'HTML'
+
+ HTML;
+ }
+
+ public function render()
+ {
+ return view('livewire.project.application.traffic-overview');
+ }
+}
diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php
index fff2b7fbf5..ad032779b3 100644
--- a/app/Livewire/Project/CloneMe.php
+++ b/app/Livewire/Project/CloneMe.php
@@ -102,6 +102,14 @@ class CloneMe extends Component
if (! $selectedDestination) {
throw new \Exception('Destination not found.');
}
+ auditLog('ui.project.clone_started', [
+ 'team_id' => $this->project->team_id,
+ 'project_uuid' => $this->project->uuid,
+ 'project_name' => $this->project->name,
+ 'clone_type' => $type,
+ 'new_name' => $this->newName,
+ 'destination_uuid' => $selectedDestination->uuid,
+ ]);
if ($type === 'project') {
$foundProject = Project::where('name', $this->newName)->first();
if ($foundProject) {
diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php
index 4a709d2b96..d938ebde8d 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -212,10 +212,18 @@ class BackupEdit extends Component
}
}
+ $database = $this->backup->database;
+ $backupUuid = $this->backup->uuid;
$this->backup->delete();
+ auditLog('ui.database.backup_schedule_deleted', [
+ 'team_id' => $database->team()?->id,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'backup_uuid' => $backupUuid,
+ ]);
- if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
- $serviceDatabase = $this->backup->database;
+ if ($database->getMorphClass() === ServiceDatabase::class) {
+ $serviceDatabase = $database;
return redirectRoute($this, 'project.service.database.backups', [
'project_uuid' => $this->parameters['project_uuid'],
@@ -251,9 +259,14 @@ class BackupEdit extends Component
}
DatabaseBackupJob::dispatch($this->backup);
- $this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
-
$database = $this->backup->database;
+ auditLog('ui.database.backup_started', [
+ 'team_id' => $database->team()?->id,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'backup_uuid' => $this->backup->uuid,
+ ]);
+ $this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
if ($database instanceof ServiceDatabase) {
return redirect()->route('project.service.database.backup.executions', [
diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php
index 8c83e33556..39a1960119 100644
--- a/app/Livewire/Project/Database/BackupNow.php
+++ b/app/Livewire/Project/Database/BackupNow.php
@@ -25,6 +25,13 @@ class BackupNow extends Component
}
DatabaseBackupJob::dispatch($this->backup);
+ $database = $this->backup->database;
+ auditLog('ui.database.backup_started', [
+ 'team_id' => $database->team()?->id,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'backup_uuid' => $this->backup->uuid,
+ ]);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php
index f2f8fa387d..993200b578 100644
--- a/app/Livewire/Project/Database/Heading.php
+++ b/app/Livewire/Project/Database/Heading.php
@@ -89,6 +89,7 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping database.');
StopDatabase::dispatch($this->database, false, $this->docker_cleanup);
+ $this->auditDatabaseAction('ui.database.stopped');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
}
@@ -100,6 +101,7 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = RestartDatabase::run($this->database);
+ $this->auditDatabaseAction('ui.database.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
@@ -113,6 +115,7 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = StartDatabase::run($this->database);
+ $this->auditDatabaseAction('ui.database.started');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
@@ -128,4 +131,13 @@ class Heading extends Component
],
]);
}
+
+ private function auditDatabaseAction(string $event): void
+ {
+ auditLog($event, [
+ 'team_id' => $this->database->team()?->id,
+ 'database_uuid' => $this->database->uuid,
+ 'database_name' => $this->database->name,
+ ]);
+ }
}
diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php
index 2d58746554..e5a359b30d 100644
--- a/app/Livewire/Project/Database/ImportForm.php
+++ b/app/Livewire/Project/Database/ImportForm.php
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Database;
+use App\Actions\Database\StartDatabaseImport;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
@@ -10,12 +11,13 @@ use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
-use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Rules\SafeWebhookUrl;
-use App\Support\DatabaseBackupFileValidator;
+use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
+use App\Support\DatabaseImport\DatabaseImportException;
+use App\Support\DatabaseImport\DatabaseImportSource;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
@@ -158,13 +160,15 @@ class ImportForm extends Component
public bool $dumpAll = false;
+ public bool $replaceExisting = false;
+
public string $restoreCommandText = '';
public string $customLocation = '';
public ?int $activityId = null;
- public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
+ public string $postgresqlRestoreCommand = 'pg_restore --exit-on-error -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
@@ -276,13 +280,24 @@ createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} else {
- $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
+ $this->syncPostgresqlRestoreCommand();
}
break;
}
}
+ public function updatedReplaceExisting(): void
+ {
+ $this->syncPostgresqlRestoreCommand();
+ }
+
+ private function syncPostgresqlRestoreCommand(): void
+ {
+ $replaceExisting = $this->replaceExisting ? ' --clean --if-exists' : '';
+ $this->postgresqlRestoreCommand = 'pg_restore --exit-on-error'.$replaceExisting.' -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
+ }
+
public function getContainers()
{
$this->containers = [];
@@ -446,72 +461,25 @@ EOD;
try {
$this->importRunning = true;
- $this->importCommands = [];
- $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);
-
- // Reject malicious PostgreSQL payloads before transferring the file anywhere.
- if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
- Storage::delete($backupFileName);
- $this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
-
- return true;
- }
-
- $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
- instant_scp($path, $tmpPath, $this->server);
- Storage::delete($backupFileName);
- $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
- $this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
- } elseif (filled($this->customLocation)) {
- // Validate the custom location to prevent command injection
- if (! $this->validateServerPath($this->customLocation)) {
- $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
-
- return true;
- }
- $tmpPath = '/tmp/restore_'.$this->resourceUuid;
- $escapedCustomLocation = escapeshellarg($this->customLocation);
- $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
- $this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
- } else {
- $this->dispatch('error', 'The file does not exist or has been deleted.');
-
- return true;
- }
-
- // Copy the restore command to a script file
- $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
-
- $restoreCommand = $this->buildRestoreCommand($tmpPath);
-
- $restoreCommandBase64 = base64_encode($restoreCommand);
- $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
- $this->importCommands[] = "chmod +x {$scriptPath}";
- $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
-
- $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
- $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
-
- if (! empty($this->importCommands)) {
- $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
- 'scriptPath' => $scriptPath,
- 'tmpPath' => $tmpPath,
- 'container' => $this->container,
- 'serverId' => $this->server->id,
- ]);
-
- // Track the activity ID
- $this->activityId = $activity->id;
-
- // Dispatch activity to the monitor and open slide-over
- $this->dispatch('activityMonitor', $activity->id);
- $this->dispatch('databaserestore');
- }
+ $source = Storage::exists("upload/{$this->resourceUuid}/restore")
+ ? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting)
+ : new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting);
+ $activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
+ $this->activityId = $activity->id;
+ $this->dispatch('activityMonitor', $activity->id);
+ $this->dispatch('databaserestore');
+ auditLog('ui.database.import_started', [
+ 'team_id' => $this->resource->team()?->id,
+ 'database_uuid' => $this->resource->uuid,
+ 'database_name' => $this->resource->name,
+ 'source' => 'file',
+ 'replace_existing' => $this->replaceExisting,
+ ]);
+ } catch (DatabaseImportException $e) {
+ $this->importRunning = false;
+ $this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
+ $this->importRunning = false;
handleError($e, $this);
return true;
@@ -654,121 +622,23 @@ EOD;
try {
$this->importRunning = true;
-
- $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
-
- $key = $s3Storage->key;
- $secret = $s3Storage->secret;
- $bucket = $s3Storage->bucket;
- $endpoint = $s3Storage->endpoint;
-
- // Validate bucket name to prevent command injection
- if (! $this->validateBucketName($bucket)) {
- $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
-
- return true;
- }
-
- // Clean the S3 path
- $cleanPath = ltrim($this->s3Path, '/');
-
- // Validate the S3 path to prevent command injection
- if (! $this->validateS3Path($cleanPath)) {
- $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
-
- return true;
- }
-
- // Get helper image
- $helperImage = coolifyHelperImage();
- $latestVersion = getHelperVersion();
- $fullImageName = "{$helperImage}:{$latestVersion}";
-
- // Get the database destination network
- if ($this->resource->getMorphClass() === 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->resourceUuid}";
- $helperTmpPath = '/tmp/'.basename($cleanPath);
- $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
- $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
- $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
-
- $escapedServerTmpPath = escapeshellarg($serverTmpPath);
- $escapedContainerTmpPath = escapeshellarg($containerTmpPath);
- $escapedScriptPath = escapeshellarg($scriptPath);
- $escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
- $escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
- $escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
- $restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
-
- // Prepare all commands in sequence
- $commands = [];
-
- // 1. Clean up any existing helper container and temp files from previous runs
- $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
- $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
- $commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
-
- // 2. Start helper container on the database network
- $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
-
- // 3. Configure S3 access in helper container
- $escapedEndpoint = escapeshellarg($endpoint);
- $escapedKey = escapeshellarg($key);
- $escapedSecret = escapeshellarg($secret);
- $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
-
- // 4. Check file exists in S3 (bucket and path already validated above)
- $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
- $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
-
- // 5. Download from S3 to helper container (progress shown by default)
- $escapedHelperTmpPath = escapeshellarg($helperTmpPath);
- $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
-
- // 6. Copy from helper to server, then immediately to database container
- $commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
- $commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
- $this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
-
- // 7. Cleanup helper container and server temp file immediately (no longer needed)
- $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
- $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
-
- // 8. Build and execute restore command inside database container
- $restoreCommand = $this->buildRestoreCommand($containerTmpPath);
-
- $restoreCommandBase64 = base64_encode($restoreCommand);
- $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
- $commands[] = "chmod +x {$escapedScriptPath}";
- $commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
-
- // 9. Execute restore and cleanup temp files immediately after completion
- $commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
- $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
-
- // Execute all commands with cleanup event (as safety net for edge cases)
- $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
- 'containerName' => $containerName,
- 'serverTmpPath' => $serverTmpPath,
- 'scriptPath' => $scriptPath,
- 'containerTmpPath' => $containerTmpPath,
- 'container' => $this->container,
- 'serverId' => $this->server->id,
- ]);
-
- // Track the activity ID
+ $source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting);
+ $activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$this->activityId = $activity->id;
-
- // Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
+ auditLog('ui.database.restore_started', [
+ 'team_id' => $this->resource->team()?->id,
+ 'database_uuid' => $this->resource->uuid,
+ 'database_name' => $this->resource->name,
+ 'source' => 's3',
+ 'replace_existing' => $this->replaceExisting,
+ 'storage_id' => $this->s3StorageId,
+ ]);
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
+ } catch (DatabaseImportException $e) {
+ $this->importRunning = false;
+ $this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
@@ -779,147 +649,8 @@ EOD;
return true;
}
- public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
- {
- $script = $this->buildPostgresRestoreScanScript($tmpPath);
-
- if ($script === null) {
- return null;
- }
-
- return "docker exec {$this->container} sh -c ".escapeshellarg($script);
- }
-
- /**
- * Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL
- * backup contains directives leading to OS command execution.
- *
- * Hardened against bypasses:
- * - decompresses gzip backups before scanning,
- * - converts custom-format (PGDMP) archives to SQL with pg_restore
- * before scanning, and rejects archives that cannot be inspected,
- * - strips `--` line comments and flattens newlines so multi-line and
- * comment-separated payloads (e.g. `FROM/**β/PROGRAM`) are caught,
- * - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
- */
- public function buildPostgresRestoreScanScript(string $tmpPath): ?string
- {
- if (! $this->isPostgresqlRestore()) {
- return null;
- }
-
- $escapedTmpPath = escapeshellarg($tmpPath);
-
- // Token separator PostgreSQL treats as whitespace: real whitespace or a
- // /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
- $sep = '([[:space:]]|/\\*[^*]*\\*/)';
-
- $sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program";
- $psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)";
- $escapedSqlPattern = escapeshellarg($sqlPattern);
- $escapedPsqlPattern = escapeshellarg($psqlPattern);
- $contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }";
- $scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}";
- $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null');
- $sqlScan = $scan($contents);
- $blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1';
- $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1';
-
- return << "\$inspect"; then
- {$blockedInspect}
- fi
- if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then
- {$blockedInspect}
- fi
- if {$customScan}; then
- {$blockedProgram}
- fi
-elif {$sqlScan}; then
- {$blockedProgram}
-fi
-SH;
- }
-
- private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
- {
- $command = $this->buildRestoreSafetyCheckCommand($tmpPath);
-
- if ($command !== null) {
- $commands[] = $command;
- }
- }
-
- private function isPostgresqlRestore(): bool
- {
- $morphClass = $this->resource->getMorphClass();
-
- if ($morphClass === ServiceDatabase::class) {
- return str_contains($this->resource->databaseType(), 'postgres');
- }
-
- return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql';
- }
-
public function buildRestoreCommand(string $tmpPath): string
{
- $escapedTmpPath = escapeshellarg($tmpPath);
- $morphClass = $this->resource->getMorphClass();
-
- // Handle ServiceDatabase by checking the database type
- if ($morphClass === 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 StandaloneMariadb::class:
- case 'mariadb':
- $restoreCommand = $this->mariadbRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
- } else {
- $restoreCommand .= " < {$escapedTmpPath}";
- }
- break;
- case StandaloneMysql::class:
- case 'mysql':
- $restoreCommand = $this->mysqlRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
- } else {
- $restoreCommand .= " < {$escapedTmpPath}";
- }
- break;
- case StandalonePostgresql::class:
- case 'postgresql':
- $restoreCommand = $this->postgresqlRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi";
- } else {
- $restoreCommand .= " {$escapedTmpPath}";
- }
- break;
- case StandaloneMongodb::class:
- case 'mongodb':
- $restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
- break;
- default:
- $restoreCommand = '';
- }
-
- return $restoreCommand;
+ return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll, $this->replaceExisting);
}
}
diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php
index 56c9f651f6..79c89322a7 100644
--- a/app/Livewire/Project/Service/Domains.php
+++ b/app/Livewire/Project/Service/Domains.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
+use App\Livewire\Concerns\InteractsWithDnsProviders;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Server;
use App\Models\Service;
@@ -12,6 +13,7 @@ use App\Models\ServiceApplication;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -21,6 +23,7 @@ class Domains extends Component
{
use AuthorizesRequests;
use InteractsWithCloudflareDomainConnect;
+ use InteractsWithDnsProviders;
protected bool $notifyRedirectUpdate = true;
@@ -118,6 +121,13 @@ class Domains extends Component
'confirmDomainUsage',
];
+ public function getListeners(): array
+ {
+ return array_merge($this->listeners, [
+ 'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished',
+ ]);
+ }
+
protected function rules(): array
{
return [
@@ -562,6 +572,11 @@ class Domains extends Component
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
}
+ protected function persistDomainDnsStatuses(): void
+ {
+ $this->persistAllDomainDnsStatuses();
+ }
+
protected function persistAllDomainDnsStatuses(): void
{
$byApp = [];
@@ -1065,9 +1080,15 @@ class Domains extends Component
$this->pendingAction = null;
$this->dispatch('close-modal');
$this->refreshDomains();
- $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
+ $addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
+ if ($this->configureDnsAfterDomainAdd($addedUrls)) {
+ $this->dispatch('success', 'Domain added.');
+
+ return;
+ }
+
$serviceApplicationId = (int) $app->id;
- $dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [
+ $dnsChecks = collect($addedUrls)->map(fn (string $url) => [
'url' => $url,
'check_id' => new_public_id(),
]);
@@ -1313,7 +1334,7 @@ class Domains extends Component
}
}
- public function removeDomain(int $index): void
+ public function removeDomain(int $index, string $password = '', array $selectedActions = []): void
{
try {
$this->authorize('update', $this->service);
@@ -1336,6 +1357,10 @@ class Domains extends Component
return;
}
+ if (in_array('deleteManagedDns', $selectedActions, true)) {
+ $this->deleteManagedDnsForUrl($url);
+ }
+
$this->forceSaveDomains = false;
$this->forceRemovePort = false;
$this->dispatch('success', 'Domain removed.');
@@ -1346,7 +1371,7 @@ class Domains extends Component
}
}
- public function removeDomainByKey(string $domainKey): void
+ public function removeDomainByKey(string $domainKey, string $password = '', array $selectedActions = []): void
{
$index = collect($this->domainRows)->search(
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
@@ -1357,7 +1382,7 @@ class Domains extends Component
return;
}
- $this->removeDomain((int) $index);
+ $this->removeDomain((int) $index, $password, $selectedActions);
}
/**
@@ -1368,6 +1393,18 @@ class Domains extends Component
return hash('sha256', $row['url'].'|'.$row['service_application_id']);
}
+ protected function dnsResourceForHostname(string $hostname): ?Model
+ {
+ foreach ($this->domainRows as $row) {
+ $rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
+ if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) {
+ return $this->findServiceApp((int) $row['service_application_id']);
+ }
+ }
+
+ return null;
+ }
+
public function addSuggestedDomain(int $index): void
{
try {
diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php
index 0e7fed960f..d692a71546 100644
--- a/app/Livewire/Project/Service/Heading.php
+++ b/app/Livewire/Project/Service/Heading.php
@@ -116,6 +116,7 @@ class Heading extends Component
try {
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
+ $this->auditServiceAction('ui.service.started');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -149,6 +150,7 @@ class Heading extends Component
try {
$this->authorizeService('stop');
StopService::dispatch($this->service, false, $this->docker_cleanup);
+ $this->auditServiceAction('ui.service.stopped');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -165,6 +167,7 @@ class Heading extends Component
return;
}
$activity = StartService::run($this->service, stopBeforeStart: true);
+ $this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -206,6 +209,7 @@ class Heading extends Component
return;
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
+ $this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -222,6 +226,15 @@ class Heading extends Component
$this->authorize($ability, $this->service);
}
+ private function auditServiceAction(string $event): void
+ {
+ auditLog($event, [
+ 'team_id' => $this->service->team()?->id,
+ 'service_uuid' => $this->service->uuid,
+ 'service_name' => $this->service->name,
+ ]);
+ }
+
public function render()
{
return view('livewire.project.service.heading', [
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index adb19a3135..10079276f2 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -78,6 +78,7 @@ class Storage extends Component
$this->activeTab = $this->resolveDefaultTab();
$this->fileStorage = collect();
$this->loadFileStorageForActiveTab();
+ $this->name = $this->generateDefaultVolumeName();
}
public function refreshStoragesFromEvent()
@@ -208,9 +209,7 @@ class Storage extends Component
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
- 'host_path' => $this->isSwarm
- ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
- : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
+ 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
], array_merge(ValidationPatterns::volumeNameMessages(), [
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
]));
@@ -343,7 +342,7 @@ class Storage extends Component
public function clearForm()
{
- $this->name = '';
+ $this->name = $this->generateDefaultVolumeName();
$this->mount_path = '';
$this->host_path = null;
$this->file_storage_path = '';
@@ -376,6 +375,13 @@ class Storage extends Component
throw new \Exception('No valid resource type for file mount storage type!');
}
+ private function generateDefaultVolumeName(): string
+ {
+ $name = str($this->resource->name)->slug()->value();
+
+ return ($name ?: 'volume').'-data';
+ }
+
public function fileStoragePreviewPath(): string
{
$path = str($this->file_storage_path)->trim();
diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php
index 94fb4b4eb3..9262b9847e 100644
--- a/app/Livewire/Project/Shared/Destination.php
+++ b/app/Livewire/Project/Shared/Destination.php
@@ -64,6 +64,13 @@ class Destination extends Component
$this->authorize('deploy', $this->resource);
$server = Server::ownedByCurrentTeam()->findOrFail($serverId);
StopApplicationOneServer::run($this->resource, $server);
+ auditLog('ui.application.destination_stopped', [
+ 'team_id' => $this->resource->team()?->id,
+ 'application_uuid' => $this->resource->uuid,
+ 'application_name' => $this->resource->name,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ ]);
$this->refreshServers();
} catch (\Exception $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index 1dcb7c7810..15b4410a5f 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
@@ -9,14 +9,27 @@ use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
+use App\Traits\HasSecretManagerAutocomplete;
use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Add extends Component
{
- use AuthorizesRequests, EnvironmentVariableAnalyzer;
+ use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete;
+
+ protected function secretManagerResource(): ?Model
+ {
+ if ($this->shared || ! $this->resource) {
+ return null;
+ }
+
+ return $this->resource;
+ }
+
+ public $resource;
public $parameters;
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index d42184f650..e47d3818fe 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -13,7 +13,9 @@ use App\Models\SharedEnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
+use App\Traits\HasSecretManagerAutocomplete;
use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -22,7 +24,12 @@ class Show extends Component
{
public bool $showEnvironmentType = true;
- use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
+ use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete;
+
+ protected function secretManagerResource(): ?Model
+ {
+ return $this->isSharedVariable ? null : $this->env->resourceable;
+ }
public $parameters;
@@ -164,7 +171,24 @@ class Show extends Component
$this->valuesLoaded = true;
}
+ public function copyValue(): ?string
+ {
+ if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
+ return null;
+ }
+
+ if (! $this->env instanceof ModelsEnvironmentVariable) {
+ return $this->env->value;
+ }
+
+ return $this->env->get_real_environment_variables_with_server(
+ $this->env->resolveReferencedValue(),
+ $this->env->resourceable,
+ );
+ }
+
private function syncData(bool $toModel = false): void
+
{
if ($toModel) {
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
@@ -207,7 +231,7 @@ class Show extends Component
$this->is_required = (bool) ($this->env->is_required ?? false);
// Use the stored column, not the value-based accessor (that decrypts).
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
- $this->isValueHidden = auth()->user()?->isMember() ?? false;
+ $this->isValueHidden = auth()->user()?->isMember() ?? true;
if ($this->valuesLoaded) {
$this->hydrateValueFields();
@@ -234,12 +258,12 @@ class Show extends Component
$this->is_really_required = $this->is_required && blank($this->value);
}
- if ($this->env->is_shown_once || auth()->user()?->isMember()) {
+ if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
$this->value = null;
$this->real_value = null;
}
- $this->isValueHidden = auth()->user()?->isMember() ?? false;
+ $this->isValueHidden = auth()->user()?->isMember() ?? true;
}
public function checkEnvs()
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php
index da55dee197..c2f0059399 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\EnvironmentVariable;
use Livewire\Component;
class ShowHardcoded extends Component
@@ -20,6 +21,10 @@ class ShowHardcoded extends Component
public bool $isPreview = false;
+ public ?string $resourceableType = null;
+
+ public ?int $resourceableId = null;
+
public function mount()
{
$this->key = $this->env['key'];
@@ -28,6 +33,20 @@ class ShowHardcoded extends Component
$this->serviceName = $this->env['service_name'] ?? null;
}
+ public function copyValue(): ?string
+ {
+ if (auth()->user()?->isMember() ?? true) {
+ return null;
+ }
+
+ return EnvironmentVariable::make([
+ 'value' => $this->value,
+ 'is_preview' => $this->isPreview,
+ 'resourceable_type' => $this->resourceableType,
+ 'resourceable_id' => $this->resourceableId,
+ ])->resolveReferencedValue();
+ }
+
public function render()
{
return view('livewire.project.shared.environment-variable.show-hardcoded');
diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php
index dd00be25cc..61b4b2d2ed 100644
--- a/app/Livewire/Project/Shared/ResourceOperations.php
+++ b/app/Livewire/Project/Shared/ResourceOperations.php
@@ -86,6 +86,14 @@ class ResourceOperations extends Component
if (! $server->canHostResources()) {
return $this->addError('destination_id', 'The selected server cannot host resources.');
}
+ auditLog('ui.resource.clone_started', [
+ 'team_id' => $this->resource->team()?->id,
+ 'resource_uuid' => $this->resource->uuid,
+ 'resource_name' => $this->resource->name,
+ 'resource_type' => class_basename($this->resource),
+ 'destination_uuid' => $new_destination->uuid,
+ 'environment_id' => $new_environment->id,
+ ]);
if ($this->resource->getMorphClass() === Application::class) {
$new_resource = clone_application($this->resource, $new_destination, [
diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php
index 30d1024621..c121f1b93b 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Show.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php
@@ -184,6 +184,13 @@ class Show extends Component
$this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
ScheduledTaskJob::dispatch($this->task);
+ auditLog('ui.scheduled_task.executed', [
+ 'team_id' => $this->resource->team()?->id,
+ 'resource_uuid' => $this->resource->uuid,
+ 'resource_name' => $this->resource->name,
+ 'scheduled_task_uuid' => $this->task->uuid,
+ 'scheduled_task_name' => $this->task->name,
+ ]);
$this->dispatch('success', 'Scheduled task executed.');
} catch (\Exception $e) {
return handleError($e);
diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php
new file mode 100644
index 0000000000..c0641b56c5
--- /dev/null
+++ b/app/Livewire/Project/Shared/SecretManagerLinks.php
@@ -0,0 +1,290 @@
+ Remote key names only β values are never stored. */
+ public array $keys = [];
+
+ public bool $keysLoaded = false;
+
+ public string $search = '';
+
+ public function mount(): void
+ {
+ $this->loadData();
+ }
+
+ private function loadData(): void
+ {
+ $this->link = $this->resource->secretManagerLink()->with('integrationToken')->first();
+ $this->availableTokens = IntegrationToken::ownedByCurrentTeam()
+ ->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
+ ->get()
+ ->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true))
+ ->values();
+
+ if ($this->link) {
+ $this->integration_token_uuid = $this->link->integrationToken->uuid;
+ $this->settings = $this->link->settings ?? [];
+ }
+ }
+
+ public function getSelectedTokenProperty(): ?IntegrationToken
+ {
+ if (blank($this->integration_token_uuid)) {
+ return null;
+ }
+
+ return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid);
+ }
+
+ protected function rules(): array
+ {
+ $rules = [
+ 'integration_token_uuid' => ['required', 'string'],
+ ];
+
+ $rules += match ($this->selectedToken?->provider) {
+ 'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account'
+ ? [
+ 'settings.project' => ['required', 'string'],
+ 'settings.config' => ['required', 'string'],
+ ]
+ : [],
+ 'infisical' => [
+ 'settings.project_id' => ['required', 'string'],
+ 'settings.environment' => ['required', 'string'],
+ 'settings.secret_path' => ['nullable', 'string'],
+ ],
+ 'vault' => [
+ 'settings.mount' => ['required', 'string'],
+ 'settings.path' => ['required', 'string'],
+ ],
+ default => [],
+ };
+
+ return $rules;
+ }
+
+ /**
+ * Auto-save when a token is selected in the dropdown. Existing {{vault.*}}
+ * references are intentionally NOT re-checked β missing keys surface at
+ * the next deployment.
+ */
+ public function updatedIntegrationTokenUuid(): void
+ {
+ try {
+ $this->authorize('update', $this->resource);
+ $token = $this->selectedToken;
+
+ if (! $token) {
+ return;
+ }
+
+ if ($this->link?->integrationToken?->provider !== $token->provider
+ || $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) {
+ $this->settings = [];
+ }
+
+ $settings = array_filter($this->settings, fn ($value) => filled($value));
+
+ $this->resource->secretManagerLink()->updateOrCreate([], [
+ 'integration_token_id' => $token->id,
+ 'settings' => $settings ?: null,
+ ]);
+ $this->auditSecretManagerAction('source_updated', [
+ 'integration_token_uuid' => $token->uuid,
+ 'provider' => $token->provider,
+ ]);
+
+ $this->resetKeys();
+ $this->loadData();
+ $this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ /**
+ * Auto-save of the provider-specific settings fields (called on blur).
+ */
+ public function saveSettings(): void
+ {
+ $this->authorize('update', $this->resource);
+
+ if (! $this->link) {
+ return;
+ }
+
+ $validated = $this->validate();
+
+ try {
+
+ $settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value));
+
+ $this->link->update(['settings' => $settings ?: null]);
+ $this->auditSecretManagerAction('settings_updated');
+ $this->resetKeys();
+ $this->loadData();
+ $this->dispatch('success', 'Secret manager settings saved.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function removeSource(): void
+ {
+ try {
+ $this->authorize('update', $this->resource);
+ $token = $this->link?->integrationToken;
+ $this->resource->secretManagerLink()->delete();
+ $this->auditSecretManagerAction('source_removed', [
+ 'integration_token_uuid' => $token?->uuid,
+ 'provider' => $token?->provider,
+ ]);
+ $this->link = null;
+ $this->integration_token_uuid = '';
+ $this->settings = [];
+ $this->resetKeys();
+ $this->loadData();
+ $this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function loadKeys(): void
+ {
+ try {
+ $this->authorize('update', $this->resource);
+
+ if (! $this->link) {
+ return;
+ }
+
+ // Values are fetched into memory, reduced to key names, and discarded.
+ $keys = array_keys($this->link->fetchSecrets());
+ sort($keys);
+ $this->keys = $keys;
+ $this->keysLoaded = true;
+ $this->auditSecretManagerAction('keys_viewed', ['key_count' => count($keys)]);
+ } catch (\Throwable $e) {
+ $this->dispatch('error', 'Could not fetch keys: '.$e->getMessage());
+ }
+ }
+
+ public function addReference(string $key): void
+ {
+ try {
+ $this->authorize('update', $this->resource);
+
+ if (! in_array($key, $this->keys, true)) {
+ return;
+ }
+
+ if ($this->resource->environment_variables()->where('key', $key)->exists()) {
+ $this->dispatch('error', "A variable with the key {$key} already exists.");
+
+ return;
+ }
+
+ $this->resource->environment_variables()->create([
+ 'key' => $key,
+ 'value' => '{{vault.'.$key.'}}',
+ ]);
+ $this->auditSecretManagerAction('reference_created', ['secret_key' => $key]);
+
+ $this->dispatch('refreshEnvs');
+ $this->dispatch('success', "Added {$key} as {{vault.{$key}}}.");
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function importAll(): void
+ {
+ try {
+ $this->authorize('update', $this->resource);
+
+ if (! $this->link) {
+ return;
+ }
+
+ $imported = $this->link->importMissingReferences();
+ $this->auditSecretManagerAction('references_imported', [
+ 'key_count' => count($imported),
+ 'secret_keys' => $imported,
+ ]);
+
+ $this->dispatch('refreshEnvs');
+ $this->dispatch('success', $imported === []
+ ? 'All remote keys already exist as variables.'
+ : 'Imported '.count($imported).' keys as {{vault.KEY}} references.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ private function resetKeys(): void
+ {
+ $this->keys = [];
+ $this->keysLoaded = false;
+ $this->search = '';
+ }
+
+ /** @param array $context */
+ private function auditSecretManagerAction(string $action, array $context = []): void
+ {
+ $resourceType = str(class_basename($this->resource))->snake()->value();
+
+ auditLog("ui.{$resourceType}.secret_manager.{$action}", array_merge([
+ 'team_id' => $this->resource->team()?->id,
+ "{$resourceType}_uuid" => $this->resource->uuid,
+ "{$resourceType}_name" => $this->resource->name,
+ ], $context));
+ }
+
+ public function getFilteredKeysProperty(): array
+ {
+ if (blank($this->search)) {
+ return $this->keys;
+ }
+
+ return array_values(array_filter(
+ $this->keys,
+ fn (string $key) => stripos($key, $this->search) !== false,
+ ));
+ }
+
+ public function render(): View
+ {
+ return view('livewire.project.shared.secret-manager-links', [
+ 'selectedToken' => $this->selectedToken,
+ 'filteredKeys' => $this->filteredKeys,
+ ]);
+ }
+}
diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php
index fcd7752a0c..3dadfb46f4 100644
--- a/app/Livewire/Project/Shared/Storages/All.php
+++ b/app/Livewire/Project/Shared/Storages/All.php
@@ -108,6 +108,25 @@ class All extends Component
$this->submit($storageId);
}
+ public function clearHostPath(int $storageId): void
+ {
+ $this->authorize('update', $this->resource);
+
+ $storage = $this->findStorageOrFail($storageId);
+ if ($storage->shouldBeReadOnlyInUI()) {
+ $this->dispatch('error', 'This volume is read-only.');
+
+ return;
+ }
+
+ $storage->host_path = null;
+ $storage->save();
+ $this->forms[$storageId]['hostPath'] = null;
+
+ $this->dispatch('configurationChanged');
+ $this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.');
+ }
+
/**
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
*/
diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php
deleted file mode 100644
index c70ebc57fd..0000000000
--- a/app/Livewire/Project/Shared/Storages/Show.php
+++ /dev/null
@@ -1,201 +0,0 @@
- 'name',
- 'mountPath' => 'mount',
- 'hostPath' => 'host',
- ];
-
- protected function rules(): array
- {
- return [
- 'name' => ValidationPatterns::volumeNameRules(),
- 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
- 'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
- 'isPreviewSuffixEnabled' => 'required|boolean',
- ];
- }
-
- protected function messages(): array
- {
- return array_merge(
- ValidationPatterns::volumeNameMessages(),
- [
- 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
- 'hostPath.regex' => 'Host path must start with / and only contain safe path characters.',
- ]
- );
- }
-
- /**
- * Sync data between component properties and model
- *
- * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
- */
- private function syncData(bool $toModel = false): void
- {
- if ($toModel) {
- // Sync TO model (before save)
- $this->storage->name = $this->name;
- $this->storage->mount_path = $this->mountPath;
- $this->storage->host_path = $this->hostPath;
- $this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
- } else {
- // Sync FROM model (on load/refresh)
- $this->name = $this->storage->name;
- $this->mountPath = $this->storage->mount_path;
- $this->hostPath = $this->storage->host_path;
- $this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true;
- }
- }
-
- public function mount(): void
- {
- $this->syncData(false);
- $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
- // PR deployment volume suffixes only apply to git-based applications.
- $this->supportsPreviewSuffix = $this->resource instanceof Application
- && $this->resource->git_based()
- && filled($this->resource->git_repository)
- && ! $this->isService;
- // Parent All batches badge/url; isolated embeds still hydrate themselves.
- if (! $this->backupMetaHydrated) {
- $this->refreshBackupStatus();
- }
- }
-
- #[On('refreshVolumeBackups')]
- public function refreshBackupStatus(): void
- {
- $backup = $this->storage->scheduledBackups()->first();
-
- $this->hasEnabledBackup = $backup?->enabled ?? false;
- $this->backupUrl = null;
-
- if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) {
- return;
- }
-
- $this->resource->loadMissing('environment.project');
-
- $parameters = [
- 'project_uuid' => $this->resource->project()->uuid,
- 'environment_uuid' => $this->resource->environment->uuid,
- 'application_uuid' => $this->resource->uuid,
- ];
- $hasOtherBackups = ScheduledVolumeBackup::query()
- ->forApplication($this->resource)
- ->where('id', '!=', $backup->id)
- ->exists();
-
- $this->backupUrl = $hasOtherBackups
- ? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name])
- : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
- }
-
- public function openBackupModal(): void
- {
- $this->authorize('update', $this->resource);
- $this->showBackupModal = true;
- }
-
- #[On('modalClosed')]
- public function onModalClosed(): void
- {
- // Drop the nested Create component from the DOM after close to free snapshot weight.
- if ($this->showBackupModal) {
- $this->showBackupModal = false;
- }
- }
-
- public function instantSave(): void
- {
- $this->authorize('update', $this->resource);
- $this->validate();
-
- $this->syncData(true);
- $this->storage->save();
- $this->dispatch('success', 'Storage updated successfully');
- }
-
- public function submit()
- {
- $this->authorize('update', $this->resource);
-
- $this->validate();
- $this->syncData(true);
- $this->storage->save();
- $this->dispatch('success', 'Storage updated successfully');
- }
-
- public function delete($password, $selectedActions = [])
- {
- $this->authorize('update', $this->resource);
-
- if (! verifyPasswordConfirmation($password, $this)) {
- return 'The provided password is incorrect.';
- }
-
- if ($this->storage->scheduledBackups()->exists()) {
- $this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.');
-
- return false;
- }
-
- $this->storage->delete();
- $this->dispatch('storageCountsChanged')->to(StorageComponent::class);
- $this->dispatch('configurationChanged');
-
- return true;
- }
-}
diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php
index a8e2d72df1..86023628e2 100644
--- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php
+++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php
@@ -208,6 +208,12 @@ class VolumeBackups extends Component
}
VolumeBackupJob::dispatch($this->backup);
+ auditLog('ui.volume_backup.started', [
+ 'team_id' => $this->resource->team()?->id,
+ 'resource_uuid' => $this->resource->uuid,
+ 'resource_name' => $this->resource->name,
+ 'backup_uuid' => $this->backup->uuid,
+ ]);
$this->dispatch('success', 'Storage backup queued.');
return redirect()->route($this->routeName('executions'), $this->routeParameters());
diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php
index 5a978ac84f..a1cc4db19f 100644
--- a/app/Livewire/Security/ApiTokens.php
+++ b/app/Livewire/Security/ApiTokens.php
@@ -140,6 +140,12 @@ class ApiTokens extends Component
]);
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
+ auditLog('ui.api_token.created', [
+ 'team_id' => currentTeam()->id,
+ 'api_token_name' => $this->description,
+ 'abilities' => array_values($this->permissions),
+ 'expires_at' => $expiresAt?->toIso8601String(),
+ ]);
$this->getTokens();
// Do NOT strip the numeric prefix (e.g. "69|...") β Sanctum uses it to index and look up tokens.
session()->flash('token', $token->plainTextToken);
@@ -156,7 +162,12 @@ class ApiTokens extends Component
->where('id', $id)
->firstOrFail();
$this->authorize('delete', $token);
+ $tokenName = $token->name;
$token->delete();
+ auditLog('ui.api_token.revoked', [
+ 'team_id' => currentTeam()->id,
+ 'api_token_name' => $tokenName,
+ ]);
$this->getTokens();
} catch (\Exception $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php
new file mode 100644
index 0000000000..d10ebb1c14
--- /dev/null
+++ b/app/Livewire/Security/IntegrationTokenEditor.php
@@ -0,0 +1,213 @@
+ */
+ public array $zones = [];
+
+ public bool $automaticDns = true;
+
+ public function mount(string $integration_token_uuid): void
+ {
+ $this->integrationToken = IntegrationToken::ownedByCurrentTeam()
+ ->whereUuid($integration_token_uuid)
+ ->firstOrFail();
+
+ $this->authorize('view', $this->integrationToken);
+
+ $this->name = $this->integrationToken->name;
+ $this->capabilities = $this->integrationToken->capabilities;
+ $this->metadata = $this->integrationToken->metadata ?? [];
+ $this->loadZones();
+ $this->automaticDns = $this->integrationToken->automaticDnsEnabled();
+ }
+
+ protected function rules(): array
+ {
+ $allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets';
+
+ $rules = [
+ 'name' => ['required', 'string', 'max:255'],
+ 'newToken' => ['nullable', 'string'],
+ 'capabilities' => ['required', 'array', 'min:1'],
+ 'capabilities.*' => ['required', 'in:'.$allowedCapability],
+ 'automaticDns' => ['boolean'],
+ ];
+
+ if ($this->integrationToken->provider === 'infisical') {
+ $rules['metadata.base_url'] = ['required', 'url'];
+ $rules['metadata.client_id'] = ['required', 'string'];
+ }
+
+ if ($this->integrationToken->provider === 'vault') {
+ $rules['metadata.base_url'] = ['required', 'url'];
+ $rules['metadata.namespace'] = ['nullable', 'string'];
+ }
+
+ return $rules;
+ }
+
+ protected function messages(): array
+ {
+ return [
+ 'capabilities.required' => 'Select at least one capability.',
+ 'capabilities.min' => 'Select at least one capability.',
+ ];
+ }
+
+ public function save(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
+ {
+ $this->authorize('update', $this->integrationToken);
+ $validated = $this->validate();
+ $provider = $this->integrationToken->provider;
+ $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
+ $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
+ if ($provider === 'cloudflare') {
+ if ($validated['automaticDns']) {
+ unset($metadata['automatic_dns']);
+ } else {
+ $metadata['automatic_dns'] = false;
+ }
+ }
+ $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
+ !== collect($this->integrationToken->capabilities)->sort()->values()->all();
+ $metadataChanged = $metadata != ($this->integrationToken->metadata ?? []);
+
+ try {
+ if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged)
+ && ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) {
+ $this->dispatch('error', $validator->errorMessage($provider));
+
+ return;
+ }
+
+ $updates = [
+ 'name' => $validated['name'],
+ 'capabilities' => $validated['capabilities'],
+ 'metadata' => $metadata ?: null,
+ ];
+
+ if (filled($validated['newToken'])) {
+ $updates['token'] = $validated['newToken'];
+ }
+
+ DB::transaction(function () use ($updates, $provider, $validated, $capabilitiesChanged, $cloudflare): void {
+ $this->integrationToken->update($updates);
+ if ($provider === 'cloudflare' && (filled($validated['newToken']) || $capabilitiesChanged)) {
+ $cloudflare->syncZones($this->integrationToken);
+ }
+ });
+ $this->newToken = '';
+ $this->loadZones();
+
+ auditLog('ui.integration_token.updated', [
+ 'team_id' => currentTeam()->id,
+ 'integration_token_uuid' => $this->integrationToken->uuid,
+ 'integration_token_name' => $this->integrationToken->name,
+ 'provider' => $this->integrationToken->provider,
+ 'rotated' => array_key_exists('token', $updates),
+ ]);
+
+ $this->dispatch(
+ 'integration-token-updated',
+ uuid: $this->integrationToken->uuid,
+ name: $this->integrationToken->name,
+ capabilities: $this->integrationToken->capabilities,
+ );
+ $this->dispatch('success', 'Integration token updated successfully.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function delete(string $password = ''): void
+ {
+ $this->authorize('delete', $this->integrationToken);
+
+ if ($this->integrationToken->secretManagerLinks()->exists()) {
+ $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
+
+ return;
+ }
+
+ if ($this->integrationToken->managedDnsRecords()->exists()) {
+ $this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
+
+ return;
+ }
+
+ $uuid = $this->integrationToken->uuid;
+ $name = $this->integrationToken->name;
+ $provider = $this->integrationToken->provider;
+ $this->integrationToken->delete();
+
+ auditLog('ui.integration_token.deleted', [
+ 'team_id' => currentTeam()->id,
+ 'integration_token_uuid' => $uuid,
+ 'integration_token_name' => $name,
+ 'provider' => $provider,
+ ]);
+
+ $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid);
+ $this->dispatch('close-modal');
+ $this->dispatch('success', 'Integration token deleted successfully.');
+ }
+
+ public function refreshZones(CloudflareDnsProvider $cloudflare): void
+ {
+ $this->authorize('update', $this->integrationToken);
+ try {
+ $cloudflare->syncZones($this->integrationToken);
+ $this->integrationToken->refresh();
+ $this->loadZones();
+ $this->dispatch('success', "Cloudflare zones refreshed. {$this->zoneCount} accessible zones found.");
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function render()
+ {
+ return view('livewire.security.integration-token-editor');
+ }
+
+ private function loadZones(): void
+ {
+ $this->zones = $this->integrationToken->dnsZones()
+ ->select(['id', 'integration_token_id', 'name', 'account_name'])
+ ->withCount('managedRecords')
+ ->orderBy('name')
+ ->get()
+ ->map(fn ($zone) => [
+ 'id' => $zone->id,
+ 'name' => $zone->name,
+ 'account_name' => $zone->account_name,
+ 'managed_records_count' => $zone->managed_records_count,
+ ])
+ ->all();
+ $this->zoneCount = count($this->zones);
+ }
+}
diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php
new file mode 100644
index 0000000000..482e17bc37
--- /dev/null
+++ b/app/Livewire/Security/IntegrationTokenForm.php
@@ -0,0 +1,139 @@
+authorize('create', IntegrationToken::class);
+ }
+
+ public function updatedProvider(): void
+ {
+ if ($this->provider === 'cloudflare') {
+ $this->capabilities = ['dns'];
+ $this->metadata = [];
+ $this->automaticDns = true;
+ } else {
+ $this->capabilities = ['secrets'];
+ $this->metadata = $this->provider === 'infisical'
+ ? ['base_url' => 'https://app.infisical.com']
+ : [];
+ }
+ }
+
+ protected function rules(): array
+ {
+ $allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets';
+
+ $rules = [
+ 'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))],
+ 'name' => ['required', 'string', 'max:255'],
+ 'token' => ['required', 'string'],
+ 'capabilities' => ['required', 'array', 'min:1'],
+ 'capabilities.*' => ['required', 'in:'.$allowedCapability],
+ 'automaticDns' => ['boolean'],
+ ];
+
+ if ($this->provider === 'infisical') {
+ $rules['metadata.base_url'] = ['required', 'url:http,https'];
+ $rules['metadata.client_id'] = ['required', 'string'];
+ }
+
+ if ($this->provider === 'doppler') {
+ $rules['token'][] = 'regex:/^dp\.(st|sa)\./';
+ }
+
+ if ($this->provider === 'vault') {
+ $rules['metadata.base_url'] = ['required', 'url:http,https'];
+ $rules['metadata.namespace'] = ['nullable', 'string'];
+ }
+
+ return $rules;
+ }
+
+ protected function messages(): array
+ {
+ return [
+ 'capabilities.required' => 'Select at least one capability.',
+ 'capabilities.min' => 'Select at least one capability.',
+ 'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).',
+ ];
+ }
+
+ public function addToken(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
+ {
+ $validated = $this->validate();
+ $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
+ if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) {
+ $metadata['automatic_dns'] = false;
+ }
+
+ try {
+ if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) {
+ $this->dispatch('error', $validator->errorMessage($validated['provider']));
+
+ return;
+ }
+
+ $integrationToken = DB::transaction(function () use ($validated, $metadata, $cloudflare): IntegrationToken {
+ $token = IntegrationToken::query()->create([
+ 'provider' => $validated['provider'], 'name' => $validated['name'], 'token' => $validated['token'],
+ 'capabilities' => $validated['capabilities'], 'metadata' => $metadata ?: null, 'team_id' => currentTeam()->id,
+ ]);
+ if ($token->provider === 'cloudflare') {
+ $cloudflare->syncZones($token);
+ }
+
+ return $token;
+ });
+
+ auditLog('ui.integration_token.created', [
+ 'team_id' => currentTeam()->id,
+ 'integration_token_uuid' => $integrationToken->uuid,
+ 'integration_token_name' => $integrationToken->name,
+ 'provider' => $integrationToken->provider,
+ ]);
+
+ $this->reset(['name', 'token']);
+ $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class);
+
+ if ($this->modal_mode) {
+ $this->dispatch('close-modal');
+ }
+
+ $this->dispatch('success', 'Integration token added successfully.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function render()
+ {
+ return view('livewire.security.integration-token-form');
+ }
+}
diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php
new file mode 100644
index 0000000000..34b2b38a07
--- /dev/null
+++ b/app/Livewire/Security/IntegrationTokens.php
@@ -0,0 +1,63 @@
+authorize('viewAny', IntegrationToken::class);
+ $this->loadTokens();
+ }
+
+ #[On('integrationTokenAdded')]
+ public function loadTokens(): void
+ {
+ $this->tokens = IntegrationToken::ownedByCurrentTeam()->withCount('dnsZones')->latest()->get();
+ }
+
+ public function deleteToken(int $tokenId, string $password = ''): void
+ {
+ $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId);
+ $this->authorize('delete', $token);
+
+ if ($token->secretManagerLinks()->exists()) {
+ $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
+
+ return;
+ }
+
+ if ($token->managedDnsRecords()->exists()) {
+ $this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
+
+ return;
+ }
+
+ $tokenUuid = $token->uuid;
+ $tokenName = $token->name;
+ $provider = $token->provider;
+ $token->delete();
+ auditLog('ui.integration_token.deleted', [
+ 'team_id' => currentTeam()->id,
+ 'integration_token_uuid' => $tokenUuid,
+ 'integration_token_name' => $tokenName,
+ 'provider' => $provider,
+ ]);
+ $this->loadTokens();
+ $this->dispatch('success', 'Integration token deleted successfully.');
+ }
+
+ public function render()
+ {
+ return view('livewire.security.integration-tokens');
+ }
+}
diff --git a/app/Livewire/Server/Analytics/Show.php b/app/Livewire/Server/Analytics/Show.php
new file mode 100644
index 0000000000..abcf904e0c
--- /dev/null
+++ b/app/Livewire/Server/Analytics/Show.php
@@ -0,0 +1,26 @@
+server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
+ $this->authorize('view', $this->server);
+ }
+
+ public function render(): View
+ {
+ return view('livewire.server.analytics.show');
+ }
+}
diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php
index 1cda771a7c..567034c801 100644
--- a/app/Livewire/Server/Charts.php
+++ b/app/Livewire/Server/Charts.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Validate;
use Livewire\Component;
class Charts extends Component
@@ -23,15 +24,44 @@ class Charts extends Component
public bool $poll = true;
+ #[Validate(['required', 'integer', 'min:1'])]
+ public int|string $sentinelMetricsRefreshRateSeconds;
+
+ #[Validate(['required', 'integer', 'min:1'])]
+ public int|string $sentinelMetricsHistoryDays;
+
+ #[Validate(['required', 'integer', 'min:10'])]
+ public int|string $sentinelPushIntervalSeconds;
+
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
+ $this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
+ $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days;
+ $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
+ public function saveMetricsSettings(): void
+ {
+ try {
+ $this->authorize('update', $this->server);
+ $this->validate();
+
+ $this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
+ $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays;
+ $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds;
+ $this->server->settings->save();
+
+ $this->dispatch('success', 'Metrics settings updated. Restarting Sentinel.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
public function toggleMetrics(): void
{
try {
@@ -70,11 +100,9 @@ class Charts extends Component
try {
$cpuMetrics = $this->server->getCpuMetrics($this->interval);
$memoryMetrics = $this->server->getMemoryMetrics($this->interval);
- $this->dispatch("refreshChartData-{$this->chartId}-cpu", [
- 'seriesData' => $cpuMetrics,
- ]);
- $this->dispatch("refreshChartData-{$this->chartId}-memory", [
- 'seriesData' => $memoryMetrics,
+ $this->dispatch("refreshChartData-{$this->chartId}-metrics", [
+ 'cpuSeries' => $cpuMetrics,
+ 'memorySeries' => $memoryMetrics,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php
index 40dd92d87e..d0a8d8ca9d 100644
--- a/app/Livewire/Server/DockerCleanup.php
+++ b/app/Livewire/Server/DockerCleanup.php
@@ -134,6 +134,13 @@ class DockerCleanup extends Component
try {
$this->authorize('update', $this->server);
DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks);
+ auditLog('ui.server.docker_cleanup_started', [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'server_name' => $this->server->name,
+ 'delete_unused_volumes' => $this->deleteUnusedVolumes,
+ 'delete_unused_networks' => $this->deleteUnusedNetworks,
+ ]);
$this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.');
} catch (\Throwable $e) {
return handleError($e, $this);
diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php
index 5ce657f001..9319c856c0 100644
--- a/app/Livewire/Server/LogDrains.php
+++ b/app/Livewire/Server/LogDrains.php
@@ -177,6 +177,49 @@ class LogDrains extends Component
}
}
+ public function toggleLogDrain(string $type): void
+ {
+ $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled;
+ $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
+ $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled;
+
+ try {
+ $this->authorize('update', $this->server);
+ $this->resetErrorBag();
+
+ $enabledProperty = $this->enabledProperty($type);
+
+ if ($this->{$enabledProperty}) {
+ $this->{$enabledProperty} = false;
+ } else {
+ $this->validateLogDrainSettings($type);
+ $this->isLogDrainNewRelicEnabled = $type === 'newrelic';
+ $this->isLogDrainAxiomEnabled = $type === 'axiom';
+ $this->isLogDrainCustomEnabled = $type === 'custom';
+ }
+
+ $this->syncData(true);
+
+ if ($this->server->isLogDrainEnabled()) {
+ StartLogDrain::run($this->server);
+ $this->dispatch('success', 'Log drain service started.');
+ } else {
+ StopLogDrain::run($this->server);
+ $this->dispatch('success', 'Log drain service stopped.');
+ }
+ } catch (\Throwable $e) {
+ // Restore the previously persisted enabled flags so the UI/DB never
+ // claim a runtime state that the Start/StopLogDrain action failed to apply.
+ $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled;
+ $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled;
+ $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled;
+ $this->server->settings->save();
+ $this->syncData();
+
+ handleError($e, $this);
+ }
+ }
+
public function submit()
{
try {
@@ -192,4 +235,33 @@ class LogDrains extends Component
{
return view('livewire.server.log-drains');
}
+
+ private function enabledProperty(string $type): string
+ {
+ return match ($type) {
+ 'newrelic' => 'isLogDrainNewRelicEnabled',
+ 'axiom' => 'isLogDrainAxiomEnabled',
+ 'custom' => 'isLogDrainCustomEnabled',
+ default => throw new \InvalidArgumentException('Unknown log drain type.'),
+ };
+ }
+
+ private function validateLogDrainSettings(string $type): void
+ {
+ match ($type) {
+ 'newrelic' => $this->validate([
+ 'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
+ 'logDrainNewRelicBaseUri' => ['required', 'url'],
+ ]),
+ 'axiom' => $this->validate([
+ 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
+ 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
+ ]),
+ 'custom' => $this->validate([
+ 'logDrainCustomConfig' => ['required'],
+ 'logDrainCustomConfigParser' => ['string', 'nullable'],
+ ]),
+ default => throw new \InvalidArgumentException('Unknown log drain type.'),
+ };
+ }
}
diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php
index d9f70ea253..242b0971ec 100644
--- a/app/Livewire/Server/Navbar.php
+++ b/app/Livewire/Server/Navbar.php
@@ -101,6 +101,11 @@ class Navbar extends Component
// Always use background job for all servers
RestartProxyJob::dispatch($this->server);
+ auditLog('ui.proxy.restarted', [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'server_name' => $this->server->name,
+ ]);
} catch (\Throwable $e) {
$this->restartInitiated = false;
@@ -125,6 +130,11 @@ class Navbar extends Component
try {
$this->authorize('manageProxy', $this->server);
$activity = StartProxy::run($this->server, force: true);
+ auditLog('ui.proxy.started', [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'server_name' => $this->server->name,
+ ]);
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -136,6 +146,12 @@ class Navbar extends Component
try {
$this->authorize('manageProxy', $this->server);
StopProxy::dispatch($this->server, $forceStop);
+ auditLog('ui.proxy.stopped', [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'server_name' => $this->server->name,
+ 'force' => $forceStop,
+ ]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php
index 296fd4da5d..0454d97049 100644
--- a/app/Livewire/Server/Proxy.php
+++ b/app/Livewire/Server/Proxy.php
@@ -56,7 +56,6 @@ class Proxy extends Component
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
$this->syncData(false);
- $this->loadProxyConfiguration();
$this->clearAppliedTraefikBranchWarning();
}
diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php
index f07799fbe5..b6444e573d 100644
--- a/app/Livewire/Server/Sentinel.php
+++ b/app/Livewire/Server/Sentinel.php
@@ -20,15 +20,6 @@ class Sentinel extends Component
public ?string $sentinelUpdatedAt = null;
- #[Validate(['required', 'integer', 'min:1'])]
- public int|string $sentinelMetricsRefreshRateSeconds;
-
- #[Validate(['required', 'integer', 'min:1'])]
- public int|string $sentinelMetricsHistoryDays;
-
- #[Validate(['required', 'integer', 'min:10'])]
- public int|string $sentinelPushIntervalSeconds;
-
#[Validate(['nullable', 'url'])]
public ?string $sentinelCustomUrl = null;
@@ -56,18 +47,12 @@ class Sentinel extends Component
$this->validate();
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
$this->server->settings->sentinel_token = $this->sentinelToken;
- $this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
- $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays;
- $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds;
$this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl;
$this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled;
$this->server->settings->save();
} else {
$this->isMetricsEnabled = $this->server->settings->is_metrics_enabled;
$this->sentinelToken = $this->server->settings->sentinel_token;
- $this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
- $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days;
- $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds;
$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php
new file mode 100644
index 0000000000..c6d96df488
--- /dev/null
+++ b/app/Livewire/Server/TrafficAnalyticsSettings.php
@@ -0,0 +1,111 @@
+authorize('update', $this->server);
+ $this->syncData();
+ }
+
+ private function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->validate();
+ $this->server->settings->traffic_topn = $this->trafficTopn;
+ $this->server->settings->traffic_sample_threshold = $this->trafficSampleThreshold;
+ $this->server->settings->traffic_retention_1h_days = $this->trafficRetention1hDays;
+ $this->server->settings->traffic_retention_1d_days = $this->trafficRetention1dDays;
+ $this->server->settings->is_geoip_enabled = $this->isGeoipEnabled;
+ $this->server->settings->geoip_refresh_days = $this->geoipRefreshDays;
+ $this->server->settings->geoip_maxmind_license_key = $this->geoipMaxmindLicenseKey;
+ $this->server->settings->save();
+
+ return;
+ }
+
+ $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled();
+ $this->trafficTopn = $this->server->settings->traffic_topn;
+ $this->trafficSampleThreshold = $this->server->settings->traffic_sample_threshold;
+ $this->trafficRetention1hDays = $this->server->settings->traffic_retention_1h_days;
+ $this->trafficRetention1dDays = $this->server->settings->traffic_retention_1d_days;
+ $this->isGeoipEnabled = (bool) $this->server->settings->is_geoip_enabled;
+ $this->geoipRefreshDays = $this->server->settings->geoip_refresh_days;
+ $this->geoipMaxmindLicenseKey = $this->server->settings->geoip_maxmind_license_key;
+ }
+
+ public function toggleTrafficAnalytics(): void
+ {
+ try {
+ $this->authorize('update', $this->server);
+ if ($this->server->isSwarm() || $this->server->isBuildServer()) {
+ $this->dispatch('error', 'Traffic analytics is not supported on Swarm/Build servers.');
+
+ return;
+ }
+
+ $enable = ! $this->server->isTrafficAnalyticsEnabled();
+ ConfigureTrafficAnalytics::run($this->server, $enable);
+ $this->server->refresh();
+ $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled();
+ $this->dispatch('trafficAnalyticsStateChanged')->to(Analytics::class);
+ $this->dispatch('success', $enable
+ ? 'Traffic analytics enabled. Restarting proxy and Sentinel.'
+ : 'Traffic analytics disabled. Restarting proxy and Sentinel.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function saveTrafficAnalyticsSettings(): void
+ {
+ try {
+ $this->authorize('update', $this->server);
+ $this->syncData(true);
+ $this->dispatch('success', 'Traffic analytics settings updated. Restarting Sentinel.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function render(): View
+ {
+ return view('livewire.server.traffic-analytics-settings');
+ }
+}
diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php
index db8999c268..9fe37c10ca 100644
--- a/app/Livewire/Server/TransferImport.php
+++ b/app/Livewire/Server/TransferImport.php
@@ -123,6 +123,15 @@ class TransferImport extends Component
$this->lastWarnings = array_values((array) data_get($result, 'warnings', []));
$this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid');
+ if (! $dryRun) {
+ auditLog('ui.server.imported', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $this->importedServerUuid,
+ 'claimed' => (bool) data_get($result, 'claimed'),
+ 'adopt_mode' => $this->adoptMode,
+ ]);
+ }
+
if ($dryRun) {
$this->dispatch('success', 'Dry run completed β nothing was written.');
} elseif (data_get($result, 'claimed')) {
diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index 45aff3f3c9..4bb89c9f08 100644
--- a/app/Livewire/Settings/Advanced.php
+++ b/app/Livewire/Settings/Advanced.php
@@ -19,6 +19,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_registration_enabled;
+ #[Validate('boolean')]
+ public bool $disable_registration_when_oauth_enabled;
+
#[Validate('boolean')]
public bool $do_not_track;
@@ -61,6 +64,7 @@ class Advanced extends Component
{
return [
'is_registration_enabled' => 'boolean',
+ 'disable_registration_when_oauth_enabled' => 'boolean',
'do_not_track' => 'boolean',
'is_dns_validation_enabled' => 'boolean',
'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers],
@@ -87,6 +91,7 @@ class Advanced extends Component
$this->allowed_ips = $this->settings->allowed_ips;
$this->do_not_track = $this->settings->do_not_track;
$this->is_registration_enabled = $this->settings->is_registration_enabled;
+ $this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled;
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
@@ -203,6 +208,7 @@ class Advanced extends Component
try {
$this->authorize('update', $this->settings);
$this->settings->is_registration_enabled = $this->is_registration_enabled;
+ $this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled;
$this->settings->do_not_track = $this->do_not_track;
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
$this->settings->custom_dns_servers = $this->custom_dns_servers;
diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php
index 4b5857db50..975ce9a241 100644
--- a/app/Livewire/SettingsEmail.php
+++ b/app/Livewire/SettingsEmail.php
@@ -160,30 +160,59 @@ class SettingsEmail extends Component
$this->instantSave('Resend');
}
+ public function toggleSmtp()
+ {
+ try {
+ $this->resetErrorBag();
+
+ if ($this->smtpEnabled) {
+ $this->smtpEnabled = false;
+ $this->syncData(true);
+ $this->dispatch('success', 'SMTP settings updated.');
+ } else {
+ $this->validateSmtpSettings();
+ $this->smtpEnabled = true;
+ $this->resendEnabled = false;
+ $this->submitSmtp();
+ }
+ } catch (\Throwable $e) {
+ $this->syncData();
+
+ return handleError($e, $this);
+ }
+ }
+
+ public function toggleResend()
+ {
+ try {
+ $this->resetErrorBag();
+
+ if ($this->resendEnabled) {
+ $this->resendEnabled = false;
+ $this->syncData(true);
+ $this->dispatch('success', 'Resend settings updated.');
+ } else {
+ $this->validateResendSettings();
+ $this->resendEnabled = true;
+ $this->smtpEnabled = false;
+ $this->submitResend();
+ }
+ } catch (\Throwable $e) {
+ $this->syncData();
+
+ return handleError($e, $this);
+ }
+ }
+
public function submitSmtp()
{
try {
$this->authorize('update', $this->settings);
- $this->validate([
- 'smtpEnabled' => 'boolean',
- 'smtpFromAddress' => 'required|email',
- 'smtpFromName' => 'required|string',
- 'smtpHost' => 'required|string',
- 'smtpPort' => 'required|numeric',
- 'smtpEncryption' => 'required|string|in:starttls,tls,none',
- 'smtpUsername' => 'nullable|string',
- 'smtpPassword' => 'nullable|string',
- 'smtpTimeout' => 'nullable|numeric',
- 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
- ], [
- 'smtpFromAddress.required' => 'From Address is required.',
- 'smtpFromAddress.email' => 'Please enter a valid email address.',
- 'smtpFromName.required' => 'From Name is required.',
- 'smtpHost.required' => 'SMTP Host is required.',
- 'smtpPort.required' => 'SMTP Port is required.',
- 'smtpPort.numeric' => 'SMTP Port must be a number.',
- 'smtpEncryption.required' => 'Encryption type is required.',
- ]);
+ $this->validateSmtpSettings();
+
+ if ($this->smtpEnabled) {
+ $this->settings->resend_enabled = $this->resendEnabled = false;
+ }
$this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_host = $this->smtpHost;
@@ -210,17 +239,11 @@ class SettingsEmail extends Component
{
try {
$this->authorize('update', $this->settings);
- $this->validate([
- 'resendEnabled' => 'boolean',
- 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
- 'smtpFromAddress' => 'required|email',
- 'smtpFromName' => 'required|string',
- ], [
- 'resendApiKey.required' => 'Resend API Key is required.',
- 'smtpFromAddress.required' => 'From Address is required.',
- 'smtpFromAddress.email' => 'Please enter a valid email address.',
- 'smtpFromName.required' => 'From Name is required.',
- ]);
+ $this->validateResendSettings();
+
+ if ($this->resendEnabled) {
+ $this->settings->smtp_enabled = $this->smtpEnabled = false;
+ }
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;
@@ -237,6 +260,45 @@ class SettingsEmail extends Component
}
}
+ private function validateSmtpSettings(): void
+ {
+ $this->validate([
+ 'smtpEnabled' => 'boolean',
+ 'smtpFromAddress' => 'required|email',
+ 'smtpFromName' => 'required|string',
+ 'smtpHost' => 'required|string',
+ 'smtpPort' => 'required|numeric',
+ 'smtpEncryption' => 'required|string|in:starttls,tls,none',
+ 'smtpUsername' => 'nullable|string',
+ 'smtpPassword' => 'nullable|string',
+ 'smtpTimeout' => 'nullable|numeric',
+ 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
+ ], [
+ 'smtpFromAddress.required' => 'From Address is required.',
+ 'smtpFromAddress.email' => 'Please enter a valid email address.',
+ 'smtpFromName.required' => 'From Name is required.',
+ 'smtpHost.required' => 'SMTP Host is required.',
+ 'smtpPort.required' => 'SMTP Port is required.',
+ 'smtpPort.numeric' => 'SMTP Port must be a number.',
+ 'smtpEncryption.required' => 'Encryption type is required.',
+ ]);
+ }
+
+ private function validateResendSettings(): void
+ {
+ $this->validate([
+ 'resendEnabled' => 'boolean',
+ 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
+ 'smtpFromAddress' => 'required|email',
+ 'smtpFromName' => 'required|string',
+ ], [
+ 'resendApiKey.required' => 'Resend API Key is required.',
+ 'smtpFromAddress.required' => 'From Address is required.',
+ 'smtpFromAddress.email' => 'Please enter a valid email address.',
+ 'smtpFromName.required' => 'From Name is required.',
+ ]);
+ }
+
public function sendTestEmail()
{
try {
diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php
index 4082718191..3b24d0cd2e 100644
--- a/app/Livewire/SettingsOauth.php
+++ b/app/Livewire/SettingsOauth.php
@@ -2,53 +2,89 @@
namespace App\Livewire;
+use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Validation\ValidationException;
use Livewire\Component;
class SettingsOauth extends Component
{
use AuthorizesRequests;
+ public InstanceSettings $settings;
+
public $oauth_settings_map;
- protected function rules()
+ public ?string $selectedProvider = null;
+
+ public bool $disable_registration_when_oauth_enabled = false;
+
+ protected function rules(): array
{
- return OauthSetting::all()->reduce(function ($carry, $setting) {
- $carry["oauth_settings_map.$setting->provider.enabled"] = 'required';
- $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable';
- $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable';
- $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable';
- $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable';
- $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable';
+ return $this->validationRules();
+ }
+
+ private function validationRules(?string $provider = null): array
+ {
+ $rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) {
+ if ($provider !== null && $setting->provider !== $provider) {
+ return $carry;
+ }
+
+ $carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean';
+ $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string';
+ $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string';
+ $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https';
+ $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string';
+ $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https';
+ $carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255';
+ $carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000';
+ $carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean';
+ $carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean';
+ $carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean';
+ $carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean';
+ $carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600';
return $carry;
}, []);
+
+ if ($provider === null) {
+ $rules['disable_registration_when_oauth_enabled'] = 'boolean';
+ }
+
+ return $rules;
}
- public function mount()
+ public function mount(?string $provider = null): ?RedirectResponse
{
if (! isInstanceAdmin()) {
return redirect()->route('home');
}
- $this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) {
- $carry[$setting->provider] = [
- 'id' => $setting->id,
- 'provider' => $setting->provider,
- 'enabled' => $setting->enabled,
- 'client_id' => $setting->client_id,
- 'client_secret' => $setting->client_secret,
- 'redirect_uri' => $setting->redirect_uri,
- 'tenant' => $setting->tenant,
- 'base_url' => $setting->base_url,
- ];
- return $carry;
- }, []);
+ $this->settings = instanceSettings();
+ $this->selectedProvider = $provider;
+ $this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled;
+ $this->oauth_settings_map = OauthSetting::all()
+ ->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider)
+ ->reduce(function ($carry, $setting) {
+ $carry[$setting->provider] = $this->oauthSettingToArray($setting);
+
+ return $carry;
+ }, []);
+
+ if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) {
+ abort(404);
+ }
+
+ return null;
}
- private function updateOauthSettings(?string $provider = null)
+ private function updateOauthSettings(?string $provider = null): void
{
+ $this->validate($this->validationRules($provider));
+
if ($provider) {
$oauthData = $this->oauth_settings_map[$provider];
$oauth = OauthSetting::find($oauthData['id']);
@@ -57,78 +93,128 @@ class SettingsOauth extends Component
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
- $oauth->fill([
- 'enabled' => $oauthData['enabled'],
- 'client_id' => $oauthData['client_id'],
- 'client_secret' => $oauthData['client_secret'],
- 'redirect_uri' => $oauthData['redirect_uri'],
- 'tenant' => $oauthData['tenant'],
- 'base_url' => $oauthData['base_url'],
- ]);
-
- if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) {
- $oauth->update(['enabled' => false]);
- throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'. Please fill in all required fields.');
- }
+ $this->fillOauthSetting($oauth, $oauthData);
+ $this->ensureProviderCanBeEnabled($oauth);
$oauth->save();
- // Update the array with fresh data
- $this->oauth_settings_map[$provider] = [
- 'id' => $oauth->id,
- 'provider' => $oauth->provider,
- 'enabled' => $oauth->enabled,
- 'client_id' => $oauth->client_id,
- 'client_secret' => $oauth->client_secret,
- 'redirect_uri' => $oauth->redirect_uri,
- 'tenant' => $oauth->tenant,
- 'base_url' => $oauth->base_url,
- ];
+ $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
$this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!');
- } else {
- $errors = [];
- foreach (array_values($this->oauth_settings_map) as $settingData) {
- $oauth = OauthSetting::find($settingData['id']);
- if (! $oauth) {
- $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted.";
-
- continue;
- }
-
- $oauth->fill([
- 'enabled' => $settingData['enabled'],
- 'client_id' => $settingData['client_id'],
- 'client_secret' => $settingData['client_secret'],
- 'redirect_uri' => $settingData['redirect_uri'],
- 'tenant' => $settingData['tenant'],
- 'base_url' => $settingData['base_url'],
- ]);
-
- if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) {
- $oauth->enabled = false;
- $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled.";
- }
-
- $oauth->save();
-
- // Update the array with fresh data
- $this->oauth_settings_map[$oauth->provider] = [
- 'id' => $oauth->id,
- 'provider' => $oauth->provider,
- 'enabled' => $oauth->enabled,
- 'client_id' => $oauth->client_id,
- 'client_secret' => $oauth->client_secret,
- 'redirect_uri' => $oauth->redirect_uri,
- 'tenant' => $oauth->tenant,
- 'base_url' => $oauth->base_url,
- ];
- }
-
- if (! empty($errors)) {
- $this->dispatch('error', implode(' ', $errors));
- }
+ return;
}
+
+ $errors = [];
+ foreach (array_values($this->oauth_settings_map) as $settingData) {
+ $oauth = OauthSetting::find($settingData['id']);
+
+ if (! $oauth) {
+ $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted.";
+
+ continue;
+ }
+
+ $this->fillOauthSetting($oauth, $settingData);
+
+ if ($oauth->enabled && ! $oauth->couldBeEnabled()) {
+ $oauth->enabled = false;
+ $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled.";
+ }
+
+ if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
+ $oauth->enabled = false;
+ $errors[] = "OIDC scopes must include 'openid'. The provider has been disabled.";
+ }
+
+ $oauth->save();
+ $this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth);
+ }
+
+ instanceSettings()->update([
+ 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
+ ]);
+
+ if (! empty($errors)) {
+ $this->dispatch('error', implode(' ', $errors));
+ }
+ }
+
+ private function fillOauthSetting(OauthSetting $oauth, array $data): void
+ {
+ $oauth->fill([
+ 'enabled' => (bool) ($data['enabled'] ?? false),
+ 'client_id' => $data['client_id'] ?? null,
+ 'client_secret' => $data['client_secret'] ?? null,
+ 'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null),
+ 'tenant' => $data['tenant'] ?? null,
+ 'base_url' => $this->nullableString($data['base_url'] ?? null),
+ 'custom_label' => $data['custom_label'] ?? null,
+ 'scopes' => $data['scopes'] ?? null,
+ 'allow_registration' => (bool) ($data['allow_registration'] ?? false),
+ 'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false),
+ 'require_email_verified' => (bool) ($data['require_email_verified'] ?? true),
+ 'use_pkce' => (bool) ($data['use_pkce'] ?? true),
+ 'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60),
+ ]);
+ }
+
+ private function nullableString(mixed $value): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $value = trim((string) $value);
+
+ return $value === '' ? null : $value;
+ }
+
+ private function ensureProviderCanBeEnabled(OauthSetting $oauth): void
+ {
+ if (! $oauth->enabled) {
+ return;
+ }
+
+ if (! $oauth->couldBeEnabled()) {
+ $oauth->update(['enabled' => false]);
+ throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'. Please fill in all required fields.');
+ }
+
+ if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
+ $oauth->update(['enabled' => false]);
+ throw new \Exception("OIDC scopes must include 'openid'.");
+ }
+ }
+
+ private function oauthSettingToArray(OauthSetting $setting): array
+ {
+ return [
+ 'id' => $setting->id,
+ 'provider' => $setting->provider,
+ 'enabled' => $setting->enabled,
+ 'client_id' => $setting->client_id,
+ 'client_secret' => $setting->client_secret,
+ 'redirect_uri' => $setting->redirect_uri,
+ 'tenant' => $setting->tenant,
+ 'base_url' => $setting->base_url,
+ 'custom_label' => $setting->custom_label,
+ 'scopes' => $setting->scopes ?: 'openid email profile',
+ 'allow_registration' => $setting->allow_registration,
+ 'auto_join_root_team' => $setting->auto_join_root_team,
+ 'require_email_verified' => $setting->require_email_verified ?? true,
+ 'use_pkce' => $setting->use_pkce ?? true,
+ 'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60,
+ 'label' => $this->providerLabel($setting->provider),
+ ];
+ }
+
+ public function providerLabel(string $provider): string
+ {
+ return match ($provider) {
+ 'oidc' => 'OpenID Connect',
+ 'gitlab' => 'GitLab',
+ default => str($provider)->headline()->toString(),
+ };
}
public function instantSave(string $provider)
@@ -141,56 +227,88 @@ class SettingsOauth extends Component
}
}
- public function toggleProvider(string $provider): mixed
+ public function toggleProvider(string $provider)
{
try {
$this->authorize('update', instanceSettings());
if (! array_key_exists($provider, $this->oauth_settings_map)) {
- throw new \Exception('OAuth provider not found.');
+ abort(404);
}
- $enabling = ! $this->oauth_settings_map[$provider]['enabled'];
- if ($enabling) {
- $this->validate($this->providerRules($provider));
+ if (! (bool) $this->oauth_settings_map[$provider]['enabled']) {
+ $this->validateProviderCanBeEnabled($provider);
}
- $this->oauth_settings_map[$provider]['enabled'] = $enabling;
+ $this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled'];
$this->updateOauthSettings($provider);
- } catch (\Throwable $e) {
+ } catch (\Exception $e) {
+ $oauth = OauthSetting::where('provider', $provider)->first();
+ if ($oauth) {
+ $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
+ }
+
return handleError($e, $this);
}
-
- return null;
}
- private function providerRules(string $provider): array
+ private function validateProviderCanBeEnabled(string $provider): void
{
- $prefix = "oauth_settings_map.$provider";
- $rules = [
- "$prefix.client_id" => 'required',
- "$prefix.client_secret" => 'required',
- ];
+ $this->validate($this->validationRules($provider));
- if ($provider === 'azure') {
- $rules["$prefix.tenant"] = 'required';
+ $oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']);
+ if (! $oauth) {
+ throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
- if (in_array($provider, ['authentik', 'clerk'], true)) {
- $rules["$prefix.base_url"] = 'required';
+ $this->fillOauthSetting($oauth, [
+ ...$this->oauth_settings_map[$provider],
+ 'enabled' => true,
+ ]);
+
+ if (! $oauth->couldBeEnabled()) {
+ throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'. Please fill in all required fields.');
}
- return $rules;
+ if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
+ throw new \Exception("OIDC scopes must include 'openid'.");
+ }
}
- public function submit()
+ public function saveRegistrationPolicy(): void
+ {
+ $this->authorize('update', instanceSettings());
+ $this->validate([
+ 'disable_registration_when_oauth_enabled' => 'boolean',
+ ]);
+
+ instanceSettings()->update([
+ 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
+ ]);
+
+ $this->dispatch('success', 'Authentication settings updated successfully!');
+ }
+
+ public function submit(): void
{
try {
$this->authorize('update', instanceSettings());
- $this->updateOauthSettings();
- $this->dispatch('success', 'Instance settings updated successfully!');
- } catch (\Throwable $e) {
- return handleError($e, $this);
+ $this->updateOauthSettings($this->selectedProvider);
+
+ if ($this->selectedProvider === null) {
+ $this->dispatch('success', 'Instance settings updated successfully!');
+ }
+ } catch (ValidationException $e) {
+ throw $e;
+ } catch (\Exception $e) {
+ if ($this->selectedProvider !== null) {
+ $oauth = OauthSetting::where('provider', $this->selectedProvider)->first();
+ if ($oauth) {
+ $this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth);
+ }
+ }
+
+ handleError($e, $this);
}
}
}
diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php
new file mode 100644
index 0000000000..53cb203eff
--- /dev/null
+++ b/app/Livewire/Team/AuditLog.php
@@ -0,0 +1,73 @@
+user()->isAdminOfTeam(currentTeam()->id), 403);
+ }
+
+ public function updatedSearch(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedAction(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedSource(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatedPerPage(): void
+ {
+ $this->perPage = max(10, min(100, $this->perPage));
+ $this->resetPage();
+ }
+
+ public function render(): View
+ {
+ $search = trim($this->search);
+ $teamId = currentTeam()->id;
+ $canViewInstanceEvents = $teamId === 0 && isInstanceAdmin();
+ $visibleEvents = AuditEvent::query()->visibleToTeam($teamId, $canViewInstanceEvents);
+ $actionOptions = [
+ ['value' => 'all', 'label' => 'All actions'],
+ ...$visibleEvents->clone()
+ ->select('action')
+ ->distinct()
+ ->orderBy('action')
+ ->pluck('action')
+ ->map(fn (string $action): array => ['value' => $action, 'label' => Str::headline($action)])
+ ->all(),
+ ];
+ $events = AuditEvent::query()
+ ->visibleToTeam($teamId, $canViewInstanceEvents)
+ ->filtered($search, $this->action, $this->source)
+ ->latestFirst()
+ ->paginate($this->perPage);
+
+ return view('livewire.team.audit-log', ['actionOptions' => $actionOptions, 'events' => $events]);
+ }
+}
diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php
index 8ecafc417c..b66c49ac9e 100644
--- a/app/Livewire/Team/Invitations.php
+++ b/app/Livewire/Team/Invitations.php
@@ -22,6 +22,8 @@ class Invitations extends Component
$this->authorize('manageInvitations', currentTeam());
$invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id);
+ $invitationEmail = $invitation->email;
+ $invitationUuid = $invitation->uuid;
DB::transaction(function () use ($invitation): void {
$user = User::whereEmail($invitation->email)->first();
if (filled($user)) {
@@ -30,6 +32,11 @@ class Invitations extends Component
$invitation->delete();
});
+ auditLog('ui.team_invitation.revoked', [
+ 'team_id' => currentTeam()->id,
+ 'invitation_uuid' => $invitationUuid,
+ 'invitation_email' => $invitationEmail,
+ ]);
$this->refreshInvitations();
$this->dispatch('success', 'Invitation revoked.');
} catch (\Exception) {
diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php
index a93bf8dd92..d6ea836075 100644
--- a/app/Livewire/Team/InviteLink.php
+++ b/app/Livewire/Team/InviteLink.php
@@ -103,6 +103,13 @@ class InviteLink extends Component
'link' => $link,
'via' => $sendEmail ? 'email' : 'link',
]);
+ auditLog('ui.team_invitation.created', [
+ 'team_id' => currentTeam()->id,
+ 'invitation_uuid' => $invitation->uuid,
+ 'invitation_email' => $invitation->email,
+ 'role' => $invitation->role,
+ 'via' => $invitation->via,
+ ]);
if ($sendEmail) {
$mail = new MailMessage;
$mail->view('emails.invitation-link', [
diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php
index ab3f7938a1..f28087056f 100644
--- a/app/Livewire/Team/Member.php
+++ b/app/Livewire/Team/Member.php
@@ -30,6 +30,7 @@ class Member extends Component
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
+ $this->auditRoleUpdate($teamId, Role::ADMIN);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -50,6 +51,7 @@ class Member extends Component
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
+ $this->auditRoleUpdate($teamId, Role::OWNER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -70,6 +72,7 @@ class Member extends Component
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
+ $this->auditRoleUpdate($teamId, Role::MEMBER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -91,6 +94,12 @@ class Member extends Component
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
$this->member->clearStoredTeamIfMatches($teamId);
});
+ auditLog('ui.team_member.removed', [
+ 'team_id' => $teamId,
+ 'member_id' => $this->member->id,
+ 'member_name' => $this->member->name,
+ 'member_email' => $this->member->email,
+ ]);
// Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}");
Cache::forget("user:{$this->member->id}:team:{$teamId}");
@@ -104,4 +113,15 @@ class Member extends Component
{
return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role;
}
+
+ private function auditRoleUpdate(int $teamId, Role $role): void
+ {
+ auditLog('ui.team_member.role_updated', [
+ 'team_id' => $teamId,
+ 'member_id' => $this->member->id,
+ 'member_name' => $this->member->name,
+ 'member_email' => $this->member->email,
+ 'role' => $role->value,
+ ]);
+ }
}
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 38b8c5b0e2..2e559b7b78 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -10,11 +10,14 @@ use App\Services\DeploymentConfiguration\ConfigurationDiff;
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
+use App\Traits\Auditable;
+
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasMetrics;
use App\Traits\HasNoindexDomains;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Database\Factories\ApplicationFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -124,10 +127,8 @@ use Symfony\Component\Yaml\Yaml;
class Application extends BaseModel
{
- use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
-
/** @use HasFactory */
- use HasFactory;
+ use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -399,6 +400,7 @@ class Application extends BaseModel
$application->persistentStorages()->delete();
$application->environment_variables()->delete();
$application->environment_variables_preview()->delete();
+ $application->secretManagerLink()->delete();
foreach ($application->scheduled_tasks as $task) {
$task->delete();
}
diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php
index ee190532c4..f16f7f8f96 100644
--- a/app/Models/ApplicationDeploymentQueue.php
+++ b/app/Models/ApplicationDeploymentQueue.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Casts\EncryptedArrayCast;
+use App\Enums\ApplicationDeploymentStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
@@ -44,6 +45,44 @@ use OpenApi\Attributes as OA;
)]
class ApplicationDeploymentQueue extends Model
{
+ protected static function booted(): void
+ {
+ static::created(function (ApplicationDeploymentQueue $deployment): void {
+ if (! auth()->check() || ! $deployment->rollback) {
+ return;
+ }
+
+ $application = $deployment->application;
+ $source = $deployment->is_api ? 'api' : 'ui';
+
+ auditLog("{$source}.application.rollback", [
+ 'team_id' => $application?->team()?->id,
+ 'application_uuid' => $application?->uuid,
+ 'application_name' => $application?->name,
+ 'deployment_uuid' => $deployment->deployment_uuid,
+ 'commit' => $deployment->commit,
+ ]);
+ });
+
+ static::updated(function (ApplicationDeploymentQueue $deployment): void {
+ if (! auth()->check()
+ || ! $deployment->wasChanged('status')
+ || $deployment->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ return;
+ }
+
+ $application = $deployment->application;
+ $source = $deployment->is_api ? 'api' : 'ui';
+
+ auditLog("{$source}.deployment.cancelled", [
+ 'team_id' => $application?->team()?->id,
+ 'application_uuid' => $application?->uuid,
+ 'application_name' => $application?->name,
+ 'deployment_uuid' => $deployment->deployment_uuid,
+ ]);
+ });
+ }
+
protected $fillable = [
'application_id',
'deployment_uuid',
diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php
index 91c38b8790..18b26f454f 100644
--- a/app/Models/ApplicationSetting.php
+++ b/app/Models/ApplicationSetting.php
@@ -32,6 +32,7 @@ use OpenApi\Attributes as OA;
'is_stripprefix_enabled' => ['type' => 'boolean'],
'connect_to_docker_network' => ['type' => 'boolean'],
'custom_internal_name' => ['type' => 'string', 'nullable' => true],
+ 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true],
'is_container_label_escape_enabled' => ['type' => 'boolean'],
'is_env_sorting_enabled' => ['type' => 'boolean'],
'is_container_label_readonly_enabled' => ['type' => 'boolean'],
@@ -49,6 +50,12 @@ use OpenApi\Attributes as OA;
)]
class ApplicationSetting extends Model
{
+ /**
+ * Keeps generated names (prefix, timestamp and for compose apps the service name) well below the
+ * 63 character DNS label limit, with room for a longer suffix in the future.
+ */
+ public const MAX_CONTAINER_NAME_PREFIX_LENGTH = 30;
+
protected $casts = [
'is_static' => 'boolean',
'is_spa' => 'boolean',
@@ -106,6 +113,7 @@ class ApplicationSetting extends Model
'is_stripprefix_enabled',
'connect_to_docker_network',
'custom_internal_name',
+ 'custom_container_name_prefix',
'is_container_label_escape_enabled',
'is_env_sorting_enabled',
'is_container_label_readonly_enabled',
@@ -121,6 +129,18 @@ class ApplicationSetting extends Model
'stop_grace_period',
];
+ /**
+ * Like custom container names, a prefix must be unique per server so that uuid, custom container
+ * name and prefix each identify one container when resolving connections.
+ */
+ public static function isContainerNamePrefixInUse(string $prefix, Server $server, ?int $ignoreApplicationId = null): bool
+ {
+ return $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) {
+ return $application->id !== $ignoreApplicationId
+ && in_array($prefix, [$application->uuid, $application->settings->custom_container_name_prefix, $application->settings->custom_internal_name], true);
+ });
+ }
+
public function stopGracePeriodSeconds(): int
{
if (
diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php
new file mode 100644
index 0000000000..2383dee267
--- /dev/null
+++ b/app/Models/AuditEvent.php
@@ -0,0 +1,211 @@
+ 'array',
+ 'created_at' => 'datetime',
+ ];
+ }
+
+ public function scopeVisibleToTeam(Builder $query, int $teamId, bool $includeInstanceEvents = false): Builder
+ {
+ return $query->where(function (Builder $query) use ($includeInstanceEvents, $teamId): void {
+ $query->where('team_id', $teamId)
+ ->when($includeInstanceEvents, fn (Builder $query) => $query->orWhereNull('team_id'));
+ });
+ }
+
+ public function scopeFiltered(
+ Builder $query,
+ string $search = '',
+ string $action = 'all',
+ string $source = 'all',
+ bool $searchSensitiveFields = true,
+ ): Builder {
+ return $query
+ ->when($action !== 'all', fn (Builder $query) => $query->where('action', $action))
+ ->when($source !== 'all', fn (Builder $query) => $query->where('source', $source))
+ ->when($search !== '', function (Builder $query) use ($search, $searchSensitiveFields): void {
+ $query->where(function (Builder $query) use ($search, $searchSensitiveFields): void {
+ $query->where('event', 'like', "%{$search}%")
+ ->orWhere('description', 'like', "%{$search}%")
+ ->orWhere('resource_name', 'like', "%{$search}%")
+ ->orWhere('actor_name', 'like', "%{$search}%")
+ ->when($searchSensitiveFields, fn (Builder $query) => $query->orWhere('actor_email', 'like', "%{$search}%"));
+ });
+ });
+ }
+
+ public function scopeLatestFirst(Builder $query): Builder
+ {
+ return $query->latest('created_at')->latest('id');
+ }
+
+ /**
+ * @param array $context
+ */
+ public static function record(string $event, array $context = []): void
+ {
+ try {
+ $attributes = self::attributesFor($event, $context);
+
+ DB::afterCommit(function () use ($attributes): void {
+ defer(function () use ($attributes): void {
+ try {
+ self::query()->create($attributes);
+ } catch (Throwable $exception) {
+ Log::warning('Audit event persistence failed', [
+ 'event' => $attributes['event'],
+ 'exception' => $exception::class,
+ ]);
+ }
+ })->always();
+ });
+ } catch (Throwable $exception) {
+ Log::warning('Audit event preparation failed', [
+ 'event' => $event,
+ 'exception' => $exception::class,
+ ]);
+ }
+ }
+
+ /**
+ * @param array $context
+ * @return array
+ */
+ private static function attributesFor(string $event, array $context): array
+ {
+ $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id')
+ ?? data_get($context, 'team_id')
+ ?? currentTeam()?->id
+ ?? self::teamIdFromContext($context);
+
+ $parts = explode('.', $event);
+ $source = $parts[0] ?? 'system';
+ $resourceType = data_get($context, 'resource') ?? ($parts[1] ?? null);
+ $action = data_get($context, 'action') ?? (end($parts) ?: 'event');
+ $resourceUuid = self::firstContextValue($context, $resourceType ? "{$resourceType}_uuid" : null, '_uuid');
+ $resourceName = self::firstContextValue($context, $resourceType ? "{$resourceType}_name" : null, '_name');
+ $user = auth()->user();
+ $token = $user?->currentAccessToken();
+ $actorType = match (true) {
+ in_array($source, ['mcp', 'webhook', 'system', 'scheduler'], true) => $source,
+ $token !== null => 'api_token',
+ $user !== null => 'user',
+ default => 'system',
+ };
+
+ return [
+ 'team_id' => $teamId,
+ 'event' => $event,
+ 'source' => $source,
+ 'action' => $action,
+ 'actor_type' => $actorType,
+ 'actor_id' => $user?->id,
+ 'actor_name' => $user?->name,
+ 'actor_email' => $user?->email,
+ 'actor_token_id' => $token?->id,
+ 'actor_token_name' => $token?->name,
+ 'resource_type' => $resourceType,
+ 'resource_uuid' => $resourceUuid,
+ 'resource_name' => $resourceName,
+ 'description' => data_get($context, 'audit_description')
+ ?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)),
+ 'metadata' => self::redact($context),
+ 'ip_address' => app()->bound('request') ? request()->ip() : null,
+ 'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null,
+ ];
+ }
+
+ /**
+ * @param array $context
+ */
+ private static function teamIdFromContext(array $context): ?int
+ {
+ $applicationUuid = data_get($context, 'application_uuid');
+ if (! is_string($applicationUuid) || $applicationUuid === '') {
+ return null;
+ }
+
+ return Application::query()
+ ->where('uuid', $applicationUuid)
+ ->first()?->team()?->id;
+ }
+
+ public static function pruneExpired(): int
+ {
+ return self::query()
+ ->where('created_at', '<', now()->subDays(90))
+ ->delete();
+ }
+
+ /**
+ * @param array $context
+ */
+ private static function firstContextValue(array $context, ?string $preferredKey, string $suffix): mixed
+ {
+ if ($preferredKey !== null && filled(data_get($context, $preferredKey))) {
+ return data_get($context, $preferredKey);
+ }
+
+ $key = Arr::first(array_keys($context), fn (string $key): bool => str_ends_with($key, $suffix));
+
+ return $key ? data_get($context, $key) : null;
+ }
+
+ private static function redact(mixed $value, ?string $key = null): mixed
+ {
+ if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie/i', $key)) {
+ return '[REDACTED]';
+ }
+
+ if (! is_array($value)) {
+ return $value;
+ }
+
+ return collect($value)
+ ->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [
+ $itemKey => self::redact($item, (string) $itemKey),
+ ])
+ ->all();
+ }
+}
diff --git a/app/Models/DnsProviderZone.php b/app/Models/DnsProviderZone.php
new file mode 100644
index 0000000000..0e099ee0c4
--- /dev/null
+++ b/app/Models/DnsProviderZone.php
@@ -0,0 +1,24 @@
+belongsTo(IntegrationToken::class);
+ }
+
+ public function managedRecords(): HasMany
+ {
+ return $this->hasMany(ManagedDnsRecord::class);
+ }
+}
diff --git a/app/Models/Environment.php b/app/Models/Environment.php
index 1364d874a1..e98f13d21f 100644
--- a/app/Models/Environment.php
+++ b/app/Models/Environment.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -21,8 +22,8 @@ use OpenApi\Attributes as OA;
)]
class Environment extends BaseModel
{
+ use Auditable, HasFactory;
use ClearsGlobalSearchCache;
- use HasFactory;
use HasSafeStringAttribute;
protected $fillable = [
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index 89188b31b1..e7dd8564bc 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Support\ValidationPatterns;
+use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use OpenApi\Attributes as OA;
@@ -34,6 +35,8 @@ use OpenApi\Attributes as OA;
)]
class EnvironmentVariable extends BaseModel
{
+ use Auditable;
+
public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
protected $attributes = [
@@ -249,17 +252,21 @@ class EnvironmentVariable extends BaseModel
protected function isShared(): Attribute
{
return Attribute::make(
- get: function () {
- $type = str($this->value)->after('{{')->before('.')->value;
- if (str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}')) {
- return true;
- }
-
- return false;
- }
+ get: fn () => $this->isSharedReference(),
);
}
+ private function isSharedReference(): bool
+ {
+ if (blank($this->value)) {
+ return false;
+ }
+
+ $types = implode('|', SHARED_VARIABLE_TYPES);
+
+ return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1;
+ }
+
public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null)
{
return $this->get_real_environment_variables_internal($environment_variable, $resource, $server);
@@ -302,6 +309,23 @@ class EnvironmentVariable extends BaseModel
return $real_value;
}
+ public function resolveReferencedValue(): ?string
+ {
+ $value = $this->value;
+
+ if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) {
+ return $value;
+ }
+
+ $referencedKey = str($value)->after('$')->trim('{}')->value();
+
+ return static::where('resourceable_type', $this->resourceable_type)
+ ->where('resourceable_id', $this->resourceable_id)
+ ->where('is_preview', (bool) $this->is_preview)
+ ->where('key', $referencedKey)
+ ->first()?->value ?? $value;
+ }
+
private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
{
return $this->get_real_environment_variables_internal($environment_variable, $resource);
@@ -389,8 +413,6 @@ class EnvironmentVariable extends BaseModel
protected function updateIsShared(): void
{
- $type = str($this->value)->after('{{')->before('.')->value;
- $isShared = str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}');
- $this->is_shared = $isShared;
+ $this->is_shared = $this->isSharedReference();
}
}
diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php
index 564fbcf6a4..96c7a2d39d 100644
--- a/app/Models/GithubApp.php
+++ b/app/Models/GithubApp.php
@@ -2,11 +2,14 @@
namespace App\Models;
+use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\DB;
class GithubApp extends BaseModel
{
+ use Auditable;
+
public function delete(): ?bool
{
return DB::transaction(fn () => parent::delete());
diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php
index c6c2b84095..727ec77cd1 100644
--- a/app/Models/GitlabApp.php
+++ b/app/Models/GitlabApp.php
@@ -2,12 +2,15 @@
namespace App\Models;
+use App\Traits\Auditable;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Crypt;
class GitlabApp extends BaseModel
{
+ use Auditable;
+
protected $fillable = [
'name',
'organization',
diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php
index 26aceec354..1e7d8282a5 100644
--- a/app/Models/InstanceSettings.php
+++ b/app/Models/InstanceSettings.php
@@ -22,6 +22,7 @@ class InstanceSettings extends Model
'do_not_track',
'is_auto_update_enabled',
'is_registration_enabled',
+ 'disable_registration_when_oauth_enabled',
'next_channel',
'smtp_enabled',
'smtp_from_address',
@@ -89,6 +90,8 @@ class InstanceSettings extends Model
'allowed_ip_ranges' => 'array',
'is_auto_update_enabled' => 'boolean',
+ 'is_registration_enabled' => 'boolean',
+ 'disable_registration_when_oauth_enabled' => 'boolean',
'auto_update_frequency' => 'string',
'update_check_frequency' => 'string',
'sentinel_token' => 'encrypted',
@@ -116,6 +119,19 @@ class InstanceSettings extends Model
});
}
+ public function isPasswordRegistrationAllowed(): bool
+ {
+ if (! $this->is_registration_enabled) {
+ return false;
+ }
+
+ if (! $this->disable_registration_when_oauth_enabled) {
+ return true;
+ }
+
+ return ! OauthSetting::where('enabled', true)->exists();
+ }
+
public function fqdn(): Attribute
{
return Attribute::make(
diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php
new file mode 100644
index 0000000000..25c2f55939
--- /dev/null
+++ b/app/Models/IntegrationToken.php
@@ -0,0 +1,96 @@
+ 'Cloudflare',
+ 'doppler' => 'Doppler',
+ 'infisical' => 'Infisical',
+ 'vault' => 'HashiCorp Vault',
+ ];
+
+ protected $fillable = [
+ 'team_id',
+ 'provider',
+ 'name',
+ 'token',
+ 'capabilities',
+ 'metadata',
+ ];
+
+ protected $hidden = [
+ 'token',
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'token' => 'encrypted',
+ 'capabilities' => 'array',
+ 'metadata' => 'array',
+ ];
+ }
+
+ public function team(): BelongsTo
+ {
+ return $this->belongsTo(Team::class);
+ }
+
+ public function secretManagerLinks(): HasMany
+ {
+ return $this->hasMany(SecretManagerLink::class);
+ }
+
+ public function dnsZones(): HasMany
+ {
+ return $this->hasMany(DnsProviderZone::class);
+ }
+
+ public function managedDnsRecords(): HasMany
+ {
+ return $this->hasMany(ManagedDnsRecord::class);
+ }
+
+ public function isSecretManager(): bool
+ {
+ return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true);
+ }
+
+ public function providerName(): string
+ {
+ return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider);
+ }
+
+ public function automaticDnsEnabled(): bool
+ {
+ return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false;
+ }
+
+ public function dopplerTokenType(): ?string
+ {
+ if ($this->provider !== 'doppler') {
+ return null;
+ }
+
+ return match (true) {
+ str_starts_with($this->token, 'dp.st.') => 'service',
+ str_starts_with($this->token, 'dp.sa.') => 'service_account',
+ default => null,
+ };
+ }
+
+ public static function ownedByCurrentTeam()
+ {
+ return self::query()->where('team_id', currentTeam()->id);
+ }
+}
diff --git a/app/Models/ManagedDnsRecord.php b/app/Models/ManagedDnsRecord.php
new file mode 100644
index 0000000000..a025cce0cb
--- /dev/null
+++ b/app/Models/ManagedDnsRecord.php
@@ -0,0 +1,32 @@
+belongsTo(DnsProviderZone::class, 'dns_provider_zone_id');
+ }
+
+ public function integrationToken(): BelongsTo
+ {
+ return $this->belongsTo(IntegrationToken::class);
+ }
+
+ public function resource(): MorphTo
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/app/Models/OauthIdentity.php b/app/Models/OauthIdentity.php
new file mode 100644
index 0000000000..1edf71ad2f
--- /dev/null
+++ b/app/Models/OauthIdentity.php
@@ -0,0 +1,35 @@
+ 'array',
+ 'last_login_at' => 'datetime',
+ ];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php
index e7999134a6..7765e41160 100644
--- a/app/Models/OauthSetting.php
+++ b/app/Models/OauthSetting.php
@@ -11,7 +11,19 @@ class OauthSetting extends Model
{
use HasFactory;
- protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled'];
+ protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds'];
+
+ protected function casts(): array
+ {
+ return [
+ 'enabled' => 'boolean',
+ 'allow_registration' => 'boolean',
+ 'auto_join_root_team' => 'boolean',
+ 'require_email_verified' => 'boolean',
+ 'use_pkce' => 'boolean',
+ 'clock_skew_seconds' => 'integer',
+ ];
+ }
protected $hidden = [
'client_secret',
@@ -32,9 +44,46 @@ class OauthSetting extends Model
return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant);
case 'authentik':
case 'clerk':
+ case 'oidc':
return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url);
default:
return filled($this->client_id) && filled($this->client_secret);
}
}
+
+ /**
+ * @return array
+ */
+ public function scopeList(): array
+ {
+ $scopes = str($this->scopes ?: 'openid email profile')
+ ->replace(',', ' ')
+ ->explode(' ')
+ ->map(fn (string $scope) => trim($scope))
+ ->filter()
+ ->unique()
+ ->values()
+ ->all();
+
+ return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes;
+ }
+
+ public function loginLabel(): string
+ {
+ if (filled($this->custom_label)) {
+ return $this->custom_label;
+ }
+
+ $envLabel = config("services.{$this->provider}.custom_label");
+ if (filled($envLabel)) {
+ return $envLabel;
+ }
+
+ return __("auth.login.{$this->provider}");
+ }
+
+ public function isOidc(): bool
+ {
+ return $this->provider === 'oidc';
+ }
}
diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php
index 3f72642a57..43aa310cbc 100644
--- a/app/Models/PrivateKey.php
+++ b/app/Models/PrivateKey.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute;
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -31,7 +32,7 @@ use phpseclib3\Crypt\PublicKeyLoader;
)]
class PrivateKey extends BaseModel
{
- use HasFactory, HasSafeStringAttribute, WithRateLimiting;
+ use Auditable, HasFactory, HasSafeStringAttribute, WithRateLimiting;
protected $fillable = [
'name',
diff --git a/app/Models/Project.php b/app/Models/Project.php
index 57dbf823ce..65c21c1e78 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -20,8 +21,8 @@ use OpenApi\Attributes as OA;
)]
class Project extends BaseModel
{
+ use Auditable, HasFactory;
use ClearsGlobalSearchCache;
- use HasFactory;
use HasSafeStringAttribute;
protected $fillable = [
@@ -63,7 +64,9 @@ class Project extends BaseModel
]);
});
static::deleting(function ($project) {
- $project->environments()->delete();
+ foreach ($project->environments()->get() as $environment) {
+ $environment->delete();
+ }
$project->settings()->delete();
$shared_variables = $project->environment_variables();
foreach ($shared_variables as $shared_variable) {
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index e4b1e2fd68..3c0d9e7e95 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
+use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Validator;
class S3Storage extends BaseModel
{
- use HasFactory, HasSafeStringAttribute;
+ use Auditable, HasFactory, HasSafeStringAttribute;
private const CONNECTION_TIMEOUT_SECONDS = 15;
diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php
new file mode 100644
index 0000000000..34e4e90d12
--- /dev/null
+++ b/app/Models/SecretManagerLink.php
@@ -0,0 +1,122 @@
+ 'array',
+ ];
+ }
+
+ public function resourceable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+
+ public function integrationToken(): BelongsTo
+ {
+ return $this->belongsTo(IntegrationToken::class);
+ }
+
+ /**
+ * Fetch the secrets from the remote manager. Values live only in memory.
+ *
+ * @return array
+ */
+ public function fetchSecrets(): array
+ {
+ $token = $this->integrationToken;
+ $settings = $this->settings ?? [];
+ $metadata = $token->metadata ?? [];
+
+ return match ($token->provider) {
+ 'doppler' => (new DopplerService($token->token))->fetchSecrets(
+ data_get($settings, 'project'),
+ data_get($settings, 'config'),
+ ),
+ 'infisical' => (new InfisicalService(
+ data_get($metadata, 'base_url', 'https://app.infisical.com'),
+ (string) data_get($metadata, 'client_id'),
+ $token->token,
+ ))->fetchSecrets(
+ (string) data_get($settings, 'project_id'),
+ (string) data_get($settings, 'environment'),
+ (string) data_get($settings, 'secret_path', '/'),
+ ),
+ 'vault' => (new VaultService(
+ (string) data_get($metadata, 'base_url'),
+ $token->token,
+ data_get($metadata, 'namespace'),
+ ))->fetchSecrets(
+ (string) data_get($settings, 'mount', 'secret'),
+ (string) data_get($settings, 'path'),
+ ),
+ default => throw new \RuntimeException("Unsupported secret manager provider [{$token->provider}]."),
+ };
+ }
+
+ /**
+ * Create one {{vault.KEY}} reference variable per remote key that has no
+ * variable with that key yet. Only key names touch the database.
+ *
+ * @return list The keys that were imported
+ */
+ public function importMissingReferences(): array
+ {
+ $keys = array_keys($this->fetchSecrets());
+ sort($keys);
+
+ $existing = $this->resourceable->environment_variables()->pluck('key')->flip();
+ $imported = [];
+
+ foreach ($keys as $key) {
+ if (isset($existing[$key])) {
+ continue;
+ }
+
+ $this->resourceable->environment_variables()->create([
+ 'key' => $key,
+ 'value' => '{{vault.'.$key.'}}',
+ ]);
+ $imported[] = $key;
+ }
+
+ return $imported;
+ }
+
+ /** Short human-readable description of the remote source for the UI. */
+ public function sourceSummary(): string
+ {
+ $settings = $this->settings ?? [];
+
+ return match ($this->integrationToken->provider) {
+ 'doppler' => trim(implode('/', array_filter([
+ data_get($settings, 'project'),
+ data_get($settings, 'config'),
+ ])), '/') ?: 'token scope',
+ 'infisical' => data_get($settings, 'project_id').'/'.data_get($settings, 'environment').data_get($settings, 'secret_path', '/'),
+ 'vault' => data_get($settings, 'mount', 'secret').'/'.data_get($settings, 'path'),
+ default => '',
+ };
+ }
+}
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 6795c4ac90..15d790d5c9 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -21,6 +21,7 @@ use App\Services\DigitalOceanService;
use App\Services\HetznerService;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -111,7 +112,7 @@ use Symfony\Component\Yaml\Yaml;
class Server extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
/**
* Sentinel IP for servers that do not have a real address yet
@@ -985,6 +986,11 @@ $siteAddress {
return $this->settings->is_metrics_enabled;
}
+ public function isTrafficAnalyticsEnabled(): bool
+ {
+ return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false);
+ }
+
public function isServerApiEnabled(): bool
{
return $this->settings->is_sentinel_enabled;
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index c3fa8721c4..512247b771 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -27,6 +27,13 @@ use OpenApi\Attributes as OA;
'is_logdrain_highlight_enabled' => ['type' => 'boolean'],
'is_logdrain_newrelic_enabled' => ['type' => 'boolean'],
'is_metrics_enabled' => ['type' => 'boolean'],
+ 'is_traffic_analytics_enabled' => ['type' => 'boolean'],
+ 'traffic_topn' => ['type' => 'integer'],
+ 'traffic_sample_threshold' => ['type' => 'integer'],
+ 'traffic_retention_1h_days' => ['type' => 'integer'],
+ 'traffic_retention_1d_days' => ['type' => 'integer'],
+ 'is_geoip_enabled' => ['type' => 'boolean'],
+ 'geoip_refresh_days' => ['type' => 'integer'],
'is_reachable' => ['type' => 'boolean'],
'is_sentinel_enabled' => ['type' => 'boolean'],
'is_swarm_manager' => ['type' => 'boolean'],
@@ -106,6 +113,14 @@ class ServerSetting extends Model
'backup_compression_cpu_percentage',
'disable_application_image_retention',
'connection_timeout',
+ 'is_traffic_analytics_enabled',
+ 'traffic_topn',
+ 'traffic_sample_threshold',
+ 'traffic_retention_1h_days',
+ 'traffic_retention_1d_days',
+ 'is_geoip_enabled',
+ 'geoip_refresh_days',
+ 'geoip_maxmind_license_key',
'docker_version',
'docker_version_checked_at',
'compose_version',
@@ -123,6 +138,14 @@ class ServerSetting extends Model
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',
+ 'is_traffic_analytics_enabled' => 'boolean',
+ 'traffic_topn' => 'integer',
+ 'traffic_sample_threshold' => 'integer',
+ 'traffic_retention_1h_days' => 'integer',
+ 'traffic_retention_1d_days' => 'integer',
+ 'is_geoip_enabled' => 'boolean',
+ 'geoip_refresh_days' => 'integer',
+ 'geoip_maxmind_license_key' => 'encrypted',
'docker_version_checked_at' => 'datetime',
'compose_version_checked_at' => 'datetime',
'backup_compression_cpu_percentage' => 'integer',
@@ -140,6 +163,7 @@ class ServerSetting extends Model
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
+ 'geoip_maxmind_license_key',
];
protected static function booted()
@@ -162,9 +186,21 @@ class ServerSetting extends Model
$settings->wasChanged('sentinel_custom_url') ||
$settings->wasChanged('sentinel_metrics_refresh_rate_seconds') ||
$settings->wasChanged('sentinel_metrics_history_days') ||
- $settings->wasChanged('sentinel_push_interval_seconds')
+ $settings->wasChanged('sentinel_push_interval_seconds') ||
+ $settings->wasChanged('traffic_topn') ||
+ $settings->wasChanged('traffic_sample_threshold') ||
+ $settings->wasChanged('traffic_retention_1h_days') ||
+ $settings->wasChanged('traffic_retention_1d_days') ||
+ $settings->wasChanged('is_geoip_enabled') ||
+ $settings->wasChanged('geoip_refresh_days') ||
+ $settings->wasChanged('geoip_maxmind_license_key')
) {
- $settings->server->restartSentinel();
+ // Only recreate Sentinel when it is already enabled. Otherwise a change to a
+ // traffic/geoip tuning knob would turn Sentinel on as a side effect, because
+ // StartSentinel unconditionally sets is_sentinel_enabled = true.
+ if ($settings->is_sentinel_enabled) {
+ $settings->server->restartSentinel();
+ }
}
});
}
diff --git a/app/Models/Service.php b/app/Models/Service.php
index e963571cb4..6ed5e836f2 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -5,8 +5,11 @@ namespace App\Models;
use App\Enums\ProcessStatus;
use App\Services\ContainerStatusAggregator;
use App\Support\DomainPortOverrides;
+use App\Traits\Auditable;
+
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -43,7 +46,7 @@ use Symfony\Component\Yaml\Yaml;
)]
class Service extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
private static $parserVersion = '5';
@@ -1615,7 +1618,7 @@ class Service extends BaseModel
return 3;
});
foreach ($sorted as $env) {
- $envs->push("{$env->key}={$env->real_value}");
+ $envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}");
}
if ($envs->count() === 0) {
$commands[] = "touch {$environmentFilename} && mv {$environmentFilename} .env";
diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php
index c70bf9f08a..086cc33e50 100644
--- a/app/Models/SharedEnvironmentVariable.php
+++ b/app/Models/SharedEnvironmentVariable.php
@@ -3,11 +3,14 @@
namespace App\Models;
use App\Support\ValidationPatterns;
+use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class SharedEnvironmentVariable extends Model
{
+ use Auditable;
+
protected $fillable = [
// Core identification
'key',
diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php
index 7ca45cc3b7..6265345ee9 100644
--- a/app/Models/StandaloneClickhouse.php
+++ b/app/Models/StandaloneClickhouse.php
@@ -2,17 +2,21 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneClickhouse extends BaseModel
{
- use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
+
+ protected array $auditExclude = ['last_online_at'];
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php
index 769d9f00c4..da4804dd2d 100644
--- a/app/Models/StandaloneDragonfly.php
+++ b/app/Models/StandaloneDragonfly.php
@@ -2,17 +2,19 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
- use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php
index 15a1fe2f82..f4dbaec210 100644
--- a/app/Models/StandaloneKeydb.php
+++ b/app/Models/StandaloneKeydb.php
@@ -2,17 +2,19 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
- use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php
index 378d36395d..c923b489bd 100644
--- a/app/Models/StandaloneMariadb.php
+++ b/app/Models/StandaloneMariadb.php
@@ -2,10 +2,12 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -13,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel
{
- use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php
index 1010ca5f37..70b108087a 100644
--- a/app/Models/StandaloneMongodb.php
+++ b/app/Models/StandaloneMongodb.php
@@ -2,17 +2,19 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel
{
- use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php
index 90828bf012..6a08a4dc45 100644
--- a/app/Models/StandaloneMysql.php
+++ b/app/Models/StandaloneMysql.php
@@ -2,17 +2,19 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel
{
- use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php
index e7db812858..f8dc5c0caa 100644
--- a/app/Models/StandalonePostgresql.php
+++ b/app/Models/StandalonePostgresql.php
@@ -2,17 +2,19 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel
{
- use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php
index 3262611903..3bfcc5434e 100644
--- a/app/Models/StandaloneRedis.php
+++ b/app/Models/StandaloneRedis.php
@@ -2,17 +2,21 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
+use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
- use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
+
+ protected array $auditExclude = ['last_online_at'];
protected $fillable = [
'uuid',
diff --git a/app/Models/Tag.php b/app/Models/Tag.php
index d5cccabd8f..30844b2bb6 100644
--- a/app/Models/Tag.php
+++ b/app/Models/Tag.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
@@ -18,7 +19,7 @@ use OpenApi\Attributes as OA;
)]
class Tag extends BaseModel
{
- use HasSafeStringAttribute;
+ use Auditable, HasSafeStringAttribute;
protected $fillable = [
'name',
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 4cf6391231..6ec79f2046 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -8,6 +8,7 @@ use App\Notifications\Channels\SendsDiscord;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\Channels\SendsPushover;
use App\Notifications\Channels\SendsSlack;
+use App\Traits\Auditable;
use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -39,7 +40,7 @@ use OpenApi\Attributes as OA;
class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack
{
- use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable;
+ use Auditable, HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable;
protected $fillable = [
'name',
@@ -86,8 +87,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
}
// Transfer instance-wide sources to root team so they remain available
- GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
- GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
+ $systemWideSources = GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->get()
+ ->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get());
+ foreach ($systemWideSources as $source) {
+ $source->update(['team_id' => 0]);
+ }
// Delete non-instance-wide sources owned by this team
$teamSources = GithubApp::where('team_id', $team->id)->get()
@@ -313,6 +317,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
return $this->hasMany(CloudProviderToken::class);
}
+ public function integrationTokens()
+ {
+ return $this->hasMany(IntegrationToken::class);
+ }
+
public function sources()
{
$sources = collect([]);
diff --git a/app/Models/User.php b/app/Models/User.php
index bb810b30fd..9f037bb917 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -11,6 +11,7 @@ use App\Services\ChangelogService;
use App\Traits\DeletesUserSessions;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notifiable;
@@ -557,12 +558,26 @@ class User extends Authenticatable implements SendsEmail
&& Carbon::now()->lessThan($this->email_change_code_expires_at);
}
+ public function oauthIdentities(): HasMany
+ {
+ return $this->hasMany(OauthIdentity::class);
+ }
+
+ public function hasSsoIdentity(): bool
+ {
+ return $this->oauthIdentities()->exists();
+ }
+
/**
* Check if the user has a password set.
- * OAuth users are created without passwords.
*/
public function hasPassword(): bool
{
return ! empty($this->password);
}
+
+ public function requiresPasswordConfirmation(): bool
+ {
+ return $this->hasPassword() && ! $this->hasSsoIdentity();
+ }
}
diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php
new file mode 100644
index 0000000000..309c8167f2
--- /dev/null
+++ b/app/Policies/IntegrationTokenPolicy.php
@@ -0,0 +1,34 @@
+isAdmin();
+ }
+
+ public function create(User $user): bool
+ {
+ return $user->isAdmin();
+ }
+
+ public function view(User $user, IntegrationToken $integrationToken): bool
+ {
+ return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
+ }
+
+ public function update(User $user, IntegrationToken $integrationToken): bool
+ {
+ return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
+ }
+
+ public function delete(User $user, IntegrationToken $integrationToken): bool
+ {
+ return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 5856791662..e4d2b0a851 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,6 +2,9 @@
namespace App\Providers;
+use App\Auth\Oidc\OidcDiscoveryService;
+use App\Auth\Oidc\OidcTokenValidator;
+use App\Auth\Oidc\Socialite\OidcProvider;
use App\Models\PersonalAccessToken;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
@@ -10,6 +13,7 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\Sanctum;
+use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider
@@ -22,12 +26,11 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
$this->configureCommands();
-
$this->configureModels();
$this->configurePasswords();
$this->configureSanctumModel();
$this->configureGitHubHttp();
-
+ $this->configureOidcSocialite();
}
private function configureCommands(): void
@@ -62,6 +65,24 @@ class AppServiceProvider extends ServiceProvider
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
}
+ private function configureOidcSocialite(): void
+ {
+ if (! $this->app->bound(SocialiteFactory::class)) {
+ return;
+ }
+
+ $this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) {
+ return new OidcProvider(
+ $app['request'],
+ $app->make(OidcDiscoveryService::class),
+ $app->make(OidcTokenValidator::class),
+ '',
+ '',
+ '',
+ );
+ });
+ }
+
private function configureGitHubHttp(): void
{
Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) {
@@ -77,16 +98,5 @@ class AppServiceProvider extends ServiceProvider
])->baseUrl($api_url);
}
});
-
- Http::macro('GitLab', function (string $api_url, ?string $access_token = null) {
- $client = Http::withHeaders([
- 'Accept' => 'application/json',
- ])->baseUrl($api_url);
- if ($access_token) {
- $client = $client->withToken($access_token);
- }
-
- return $client;
- });
}
}
diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php
index 09b2a3e089..e8e6fb42c6 100644
--- a/app/Providers/AuthServiceProvider.php
+++ b/app/Providers/AuthServiceProvider.php
@@ -15,6 +15,7 @@ use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
+use App\Models\IntegrationToken;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\PushoverNotificationSettings;
@@ -52,6 +53,7 @@ use App\Policies\EnvironmentVariablePolicy;
use App\Policies\GithubAppPolicy;
use App\Policies\GitlabAppPolicy;
use App\Policies\InstanceSettingsPolicy;
+use App\Policies\IntegrationTokenPolicy;
use App\Policies\NotificationPolicy;
use App\Policies\PrivateKeyPolicy;
use App\Policies\ProjectPolicy;
@@ -132,6 +134,7 @@ class AuthServiceProvider extends ServiceProvider
// Cloud provider policies
CloudProviderToken::class => CloudProviderTokenPolicy::class,
+ IntegrationToken::class => IntegrationTokenPolicy::class,
CloudInitScript::class => CloudInitScriptPolicy::class,
Tag::class => TagPolicy::class,
diff --git a/app/Providers/DuskServiceProvider.php b/app/Providers/DuskServiceProvider.php
deleted file mode 100644
index 07e0e8709f..0000000000
--- a/app/Providers/DuskServiceProvider.php
+++ /dev/null
@@ -1,21 +0,0 @@
-visit('/login')
- ->type('email', 'test@example.com')
- ->type('password', 'password')
- ->press('Login');
- });
- }
-}
diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php
index 60d2545a05..6426860187 100644
--- a/app/Providers/FortifyServiceProvider.php
+++ b/app/Providers/FortifyServiceProvider.php
@@ -46,7 +46,7 @@ class FortifyServiceProvider extends ServiceProvider
$isFirstUser = User::count() === 0;
$settings = instanceSettings();
- if (! $settings->is_registration_enabled) {
+ if (! $settings->isPasswordRegistrationAllowed()) {
return redirect()->route('login');
}
@@ -59,13 +59,13 @@ class FortifyServiceProvider extends ServiceProvider
$settings = instanceSettings();
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
$users = User::count();
- if ($users == 0) {
- // If there are no users, redirect to registration
+ if ($users == 0 && $settings->isPasswordRegistrationAllowed()) {
+ // If there are no users and password registration is allowed, redirect to registration.
return redirect()->route('register');
}
return view('auth.login', [
- 'is_registration_enabled' => $settings->is_registration_enabled,
+ 'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(),
'enabled_oauth_providers' => $enabled_oauth_providers,
]);
});
diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php
new file mode 100644
index 0000000000..2ec8f88e3e
--- /dev/null
+++ b/app/Services/Auth/OauthLoginService.php
@@ -0,0 +1,228 @@
+email));
+ if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ throw new HttpException(403, 'OAuth provider did not return a valid email address');
+ }
+
+ $user = $provider === 'oidc'
+ ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email)
+ : $this->resolveOauthUser($oauthUser, $oauthSetting, $email);
+
+ Auth::login($user);
+ $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team();
+ session(['currentTeam' => $user->currentTeam = $team]);
+
+ return $user;
+ }
+
+ private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
+ {
+ $provider = $oauthSetting->provider;
+ $providerUserId = $oauthUser->id ?? null;
+ if (
+ (! is_string($providerUserId) && ! is_int($providerUserId))
+ || (is_string($providerUserId) && trim($providerUserId) === '')
+ ) {
+ throw new HttpException(403, 'OAuth provider did not return a valid user ID');
+ }
+ $providerUserId = (string) $providerUserId;
+ $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : [];
+
+ $identityKey = [
+ 'provider' => $provider,
+ 'issuer' => $provider,
+ 'provider_user_id' => $providerUserId,
+ ];
+
+ try {
+ return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User {
+ $identity = OauthIdentity::where($identityKey)->first();
+
+ if ($identity) {
+ $identity->update([
+ 'email' => $email,
+ 'raw_claims' => $rawClaims,
+ 'last_login_at' => now(),
+ ]);
+
+ return $identity->user;
+ }
+
+ $user = User::whereEmail($email)->first();
+ if (! $user) {
+ if (! $this->canCreateUser($oauthSetting)) {
+ throw new HttpException(403, 'Registration is disabled');
+ }
+
+ $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
+ }
+
+ OauthIdentity::create([
+ 'user_id' => $user->id,
+ 'provider' => $provider,
+ 'issuer' => $provider,
+ 'provider_user_id' => $providerUserId,
+ 'email' => $email,
+ 'raw_claims' => $rawClaims,
+ 'last_login_at' => now(),
+ ]);
+
+ return $user;
+ });
+ } catch (UniqueConstraintViolationException $exception) {
+ return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception;
+ }
+ }
+
+ private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
+ {
+ $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer)
+ ? $oauthUser->issuer
+ : data_get($oauthUser->user, 'iss');
+ $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject)
+ ? $oauthUser->subject
+ : data_get($oauthUser->user, 'sub', $oauthUser->id);
+ $emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified)
+ || data_get($oauthUser->user, 'email_verified') === true;
+
+ if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') {
+ throw new HttpException(403, 'OIDC provider did not return issuer and subject claims');
+ }
+
+ if ($oauthSetting->require_email_verified && ! $emailVerified) {
+ throw new HttpException(403, 'OIDC provider did not verify the email address');
+ }
+
+ $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : [];
+
+ $identityKey = [
+ 'provider' => 'oidc',
+ 'issuer' => $issuer,
+ 'provider_user_id' => $subject,
+ ];
+
+ try {
+ return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User {
+ $identity = OauthIdentity::where($identityKey)->first();
+
+ if ($identity) {
+ $identity->update([
+ 'email' => $email,
+ 'raw_claims' => $rawClaims,
+ 'last_login_at' => now(),
+ ]);
+
+ return $identity->user;
+ }
+
+ $user = User::whereEmail($email)->first();
+
+ // Linking a new OIDC identity to an existing local account by email
+ // is account takeover unless the provider attests the email. This
+ // guard is independent of the require_email_verified toggle, which
+ // only governs the broader login flow.
+ if ($user && ! $emailVerified) {
+ throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account');
+ }
+
+ if (! $user) {
+ if (! $this->canCreateUser($oauthSetting)) {
+ throw new HttpException(403, 'Registration is disabled');
+ }
+
+ $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
+ }
+
+ OauthIdentity::create([
+ 'user_id' => $user->id,
+ 'provider' => 'oidc',
+ 'issuer' => $issuer,
+ 'provider_user_id' => $subject,
+ 'email' => $email,
+ 'raw_claims' => $rawClaims,
+ 'last_login_at' => now(),
+ ]);
+
+ return $user;
+ });
+ } catch (UniqueConstraintViolationException $exception) {
+ return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception;
+ }
+ }
+
+ private function canCreateUser(OauthSetting $oauthSetting): bool
+ {
+ return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration;
+ }
+
+ private function createUser(string $name, string $email, OauthSetting $oauthSetting): User
+ {
+ if (User::count() === 0) {
+ $user = (new User)->forceFill([
+ 'id' => 0,
+ 'name' => $name,
+ 'email' => $email,
+ 'password' => Hash::make(Str::random(64)),
+ ]);
+ $user->save();
+
+ $team = $user->teams()->first() ?? Team::find(0);
+ if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
+ $user->teams()->attach($team, ['role' => 'owner']);
+ }
+
+ instanceSettings()->update(['is_registration_enabled' => false]);
+
+ return $user;
+ }
+
+ if ($oauthSetting->auto_join_root_team) {
+ return $this->createRootTeamOnlyUser($name, $email);
+ }
+
+ return User::create([
+ 'name' => $name,
+ 'email' => $email,
+ 'password' => Hash::make(Str::random(64)),
+ ]);
+ }
+
+ private function createRootTeamOnlyUser(string $name, string $email): User
+ {
+ return DB::transaction(function () use ($name, $email) {
+ $rootTeam = Team::find(0);
+ if ($rootTeam === null) {
+ throw new HttpException(403, 'Root team is not available for OAuth user provisioning');
+ }
+
+ $user = User::withoutEvents(fn () => User::create([
+ 'name' => $name,
+ 'email' => $email,
+ 'password' => Hash::make(Str::random(64)),
+ ]));
+
+ $user->teams()->attach($rootTeam, ['role' => 'member']);
+
+ return $user;
+ });
+ }
+}
diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php
new file mode 100644
index 0000000000..2a4a761027
--- /dev/null
+++ b/app/Services/CloudflareTokenValidator.php
@@ -0,0 +1,42 @@
+client($token);
+ $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify');
+
+ if (! $verification->successful() || $verification->json('result.status') !== 'active') {
+ return false;
+ }
+
+ if (in_array('dns', $capabilities, true)) {
+ $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]);
+ $zoneId = $zones->json('result.0.id');
+
+ if (! $zones->successful() || ! is_string($zoneId)) {
+ return false;
+ }
+
+ return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [
+ 'per_page' => 1,
+ ])->successful();
+ }
+
+ return true;
+ }
+
+ private function client(string $token): PendingRequest
+ {
+ return Http::withToken($token)
+ ->acceptJson()
+ ->connectTimeout(5)
+ ->timeout(10);
+ }
+}
diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php
new file mode 100644
index 0000000000..dab3599101
--- /dev/null
+++ b/app/Services/DatabaseStartCommandExecutor.php
@@ -0,0 +1,77 @@
+destination->server;
+ if ($server->isNonRoot()) {
+ $commands = parseCommandsByLineForSudo(collect($commands), $server)->all();
+ }
+
+ $secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction')
+ ? $database->resolvedSecretManagerValuesForRedaction()
+ : [];
+ $remoteCommand = SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands));
+
+ $activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]);
+ $activity->save();
+
+ $process = Process::timeout(config('constants.ssh.command_timeout'))
+ ->idleTimeout(3600)
+ ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets): void {
+ $this->appendOutput($activity, $type, $this->redact($output, $secrets));
+ });
+
+ $result = $process->wait();
+ $status = $result->successful() ? ProcessStatus::FINISHED : ProcessStatus::ERROR;
+ $activity->properties = $activity->properties->merge([
+ 'status' => $status->value,
+ 'exitCode' => $result->exitCode(),
+ ]);
+ $activity->save();
+
+ if (! $result->successful()) {
+ throw new \RuntimeException($this->redact($result->errorOutput(), $secrets), $result->exitCode());
+ }
+
+ return $activity;
+ }
+
+ private function redact(string $value, array $secrets): string
+ {
+ foreach ($secrets as $secret) {
+ if (is_string($secret) && $secret !== '') {
+ $value = str_replace($secret, REDACTED, $value);
+ }
+ }
+
+ return sanitize_utf8_text(remove_iip($value));
+ }
+
+ private function appendOutput(Activity $activity, string $type, string $output): void
+ {
+ if ($output === '') {
+ return;
+ }
+
+ $entries = json_decode($activity->description ?: '[]', true, flags: JSON_THROW_ON_ERROR);
+ $entries[] = [
+ 'type' => $type,
+ 'output' => $output,
+ 'timestamp' => hrtime(true),
+ 'batch' => 1,
+ 'order' => count($entries) + 1,
+ ];
+ $activity->description = json_encode($entries, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
+ $activity->save();
+ }
+}
diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php
index e3ba77163d..184aa01eb3 100644
--- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php
+++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php
@@ -170,6 +170,7 @@ class ApplicationConfigurationSnapshot
$this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'),
$this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'),
$this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'),
+ $this->item('custom_container_name_prefix', 'Container name prefix', data_get($this->application, 'settings.custom_container_name_prefix'), 'redeploy'),
$this->item('is_consistent_container_name_enabled', 'Consistent container name', data_get($this->application, 'settings.is_consistent_container_name_enabled'), 'redeploy'),
$this->item('is_container_label_escape_enabled', 'Escape container labels', data_get($this->application, 'settings.is_container_label_escape_enabled'), 'redeploy'),
$this->item('is_container_label_readonly_enabled', 'Read-only container labels', data_get($this->application, 'settings.is_container_label_readonly_enabled'), 'redeploy'),
diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php
new file mode 100644
index 0000000000..04470b3350
--- /dev/null
+++ b/app/Services/Dns/CloudflareDnsProvider.php
@@ -0,0 +1,188 @@
+> */
+ private array $zoneCache = [];
+
+ public function syncZones(IntegrationToken $token): int
+ {
+ unset($this->zoneCache[$token->team_id]);
+ $zones = [];
+ $page = 1;
+ do {
+ $response = $this->client($token)->get('https://api.cloudflare.com/client/v4/zones', ['page' => $page, 'per_page' => 50]);
+ if (! $response->successful() || $response->json('success') !== true) {
+ throw new RuntimeException('Cloudflare zones could not be synchronized.');
+ }
+ array_push($zones, ...$response->json('result', []));
+ $totalPages = max(1, (int) $response->json('result_info.total_pages', 1));
+ $page++;
+ } while ($page <= $totalPages);
+
+ DB::transaction(function () use ($token, $zones): void {
+ $ids = [];
+ foreach ($zones as $zone) {
+ $ids[] = $zone['id'];
+ $token->dnsZones()->updateOrCreate(['provider_zone_id' => $zone['id']], [
+ 'name' => strtolower($zone['name']), 'account_id' => data_get($zone, 'account.id'),
+ 'account_name' => data_get($zone, 'account.name'),
+ ]);
+ }
+ $token->dnsZones()->whereNotIn('provider_zone_id', $ids)->whereDoesntHave('managedRecords')->delete();
+ $metadata = $token->metadata ?? [];
+ $metadata['zones_synced_at'] = now()->toIso8601String();
+ $token->update(['metadata' => $metadata]);
+ });
+
+ return count($zones);
+ }
+
+ /** @return Collection */
+ public function findZones(int $teamId, string $hostname): Collection
+ {
+ $hostname = strtolower(rtrim($hostname, '.'));
+ $matches = $this->zonesForTeam($teamId)->filter(
+ fn (DnsProviderZone $zone) => $hostname === $zone->name || str_ends_with($hostname, '.'.$zone->name)
+ );
+ $longest = $matches->max(fn (DnsProviderZone $zone) => strlen($zone->name));
+
+ return $matches->filter(fn (DnsProviderZone $zone) => strlen($zone->name) === $longest)->values();
+ }
+
+ /** @return Collection */
+ private function zonesForTeam(int $teamId): Collection
+ {
+ return $this->zoneCache[$teamId] ??= DnsProviderZone::query()
+ ->whereHas('integrationToken', fn ($query) => $query->where('team_id', $teamId)->where('provider', 'cloudflare'))
+ ->with('integrationToken')
+ ->get();
+ }
+
+ /**
+ * @return array{id: string, type: string, name: string, content: string}|null
+ */
+ public function findRecord(DnsProviderZone $zone, string $hostname, string $type): ?array
+ {
+ $hostname = strtolower(rtrim($hostname, '.'));
+ $response = $this->client($zone->integrationToken)->get(
+ "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records",
+ ['type' => $type, 'name' => $hostname, 'per_page' => 100],
+ );
+ if (! $response->successful()) {
+ throw new RuntimeException('Cloudflare DNS records could not be checked.');
+ }
+ $remote = collect($response->json('result', []))->first();
+ if ($remote === null) {
+ return null;
+ }
+
+ return [
+ 'id' => (string) ($remote['id'] ?? ''),
+ 'type' => (string) ($remote['type'] ?? $type),
+ 'name' => strtolower((string) ($remote['name'] ?? $hostname)),
+ 'content' => (string) ($remote['content'] ?? ''),
+ ];
+ }
+
+ public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null): ManagedDnsRecord
+ {
+ $hostname = strtolower(rtrim($hostname, '.'));
+ $type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A';
+ $remote = $this->findRecord($zone, $hostname, $type);
+ if ($remote !== null) {
+ if ($remote['content'] === $content) {
+ if ($remote['id'] === '') {
+ throw new RuntimeException('Cloudflare DNS records could not be checked.');
+ }
+
+ return $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource);
+ }
+ throw new DnsRecordConflictException($remote['id'], $remote['content'], $content);
+ }
+ $response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [
+ 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false,
+ ]);
+ if (! $response->successful() || ! is_string($response->json('result.id'))) {
+ throw new RuntimeException('Cloudflare could not create the DNS record.');
+ }
+
+ return $this->trackRecord($zone, $response->json('result.id'), $type, $hostname, $content, $resource);
+ }
+
+ public function replaceRecord(
+ DnsProviderZone $zone,
+ string $recordId,
+ string $hostname,
+ string $content,
+ ?Model $resource = null,
+ ?string $expectedCurrent = null,
+ ): ManagedDnsRecord {
+ $hostname = strtolower(rtrim($hostname, '.'));
+ $type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A';
+ $remote = $this->findRecord($zone, $hostname, $type);
+ if ($remote === null
+ || $remote['id'] === ''
+ || $remote['id'] !== $recordId
+ || ($expectedCurrent !== null && $remote['content'] !== $expectedCurrent)
+ || $remote['name'] !== $hostname) {
+ throw new RuntimeException('The DNS conflict is no longer available. Check the record again.');
+ }
+
+ $response = $this->client($zone->integrationToken)->put(
+ "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$remote['id']}",
+ ['type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false],
+ );
+ if (! $response->successful()) {
+ throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.');
+ }
+
+ return $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource);
+ }
+
+ public function deleteRecord(ManagedDnsRecord $record): bool
+ {
+ $record->loadMissing(['zone', 'integrationToken']);
+ $url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}";
+ $response = $this->client($record->integrationToken)->get($url);
+ $remote = $response->json('result');
+ if (! $response->successful() || ($remote['type'] ?? null) !== $record->type
+ || strtolower((string) ($remote['name'] ?? '')) !== $record->name || ($remote['content'] ?? null) !== $record->content) {
+ return false;
+ }
+ if (! $this->client($record->integrationToken)->delete($url)->successful()) {
+ return false;
+ }
+ $record->delete();
+
+ return true;
+ }
+
+ private function trackRecord(DnsProviderZone $zone, string $recordId, string $type, string $name, string $content, ?Model $resource): ManagedDnsRecord
+ {
+ return ManagedDnsRecord::query()->updateOrCreate(
+ ['dns_provider_zone_id' => $zone->id, 'provider_record_id' => $recordId],
+ ['team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id,
+ 'resource_type' => $resource?->getMorphClass(), 'resource_id' => $resource?->getKey(),
+ 'type' => $type, 'name' => $name, 'content' => $content],
+ );
+ }
+
+ private function client(IntegrationToken $token): PendingRequest
+ {
+ return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(10);
+ }
+}
diff --git a/app/Services/DopplerService.php b/app/Services/DopplerService.php
new file mode 100644
index 0000000000..2513a4f7d8
--- /dev/null
+++ b/app/Services/DopplerService.php
@@ -0,0 +1,57 @@
+client()->get($this->baseUrl.'/v3/me')->successful();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * Download all secrets for a config. Project and config are not needed for
+ * service tokens (the token itself is pinned to one config).
+ *
+ * @return array
+ */
+ public function fetchSecrets(?string $project = null, ?string $config = null): array
+ {
+ $query = ['format' => 'json'];
+ if (filled($project)) {
+ $query['project'] = $project;
+ }
+ if (filled($config)) {
+ $query['config'] = $config;
+ }
+
+ $response = $this->client()->get($this->baseUrl.'/v3/configs/config/secrets/download', $query);
+
+ if (! $response->successful()) {
+ throw new \RuntimeException('Doppler API error: '.($response->json('messages.0') ?? 'HTTP '.$response->status()));
+ }
+
+ return collect($response->json())
+ ->map(fn ($value) => is_string($value) ? $value : json_encode($value))
+ ->all();
+ }
+
+ private function client(): PendingRequest
+ {
+ return Http::withToken($this->token)
+ ->acceptJson()
+ ->connectTimeout(5)
+ ->timeout(10);
+ }
+}
diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php
new file mode 100644
index 0000000000..06f1e5d49f
--- /dev/null
+++ b/app/Services/InfisicalService.php
@@ -0,0 +1,89 @@
+ */
+ private array $httpClientOptions;
+
+ public function __construct(string $baseUrl, private string $clientId, private string $clientSecret)
+ {
+ $this->baseUrl = rtrim($baseUrl, '/');
+ Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate();
+ $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl);
+ }
+
+ public function validate(): bool
+ {
+ try {
+ $this->login();
+
+ return true;
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function fetchSecrets(string $projectId, string $environment, string $secretPath = '/'): array
+ {
+ $client = $this->client()->withToken($this->login());
+ $secretPath = $secretPath ?: '/';
+
+ $response = $client->get($this->baseUrl.'/api/v4/secrets', [
+ 'projectId' => $projectId,
+ 'environment' => $environment,
+ 'secretPath' => $secretPath,
+ ]);
+
+ // Older self-hosted instances only expose the v3 endpoint.
+ if ($response->status() === 404) {
+ $response = $client->get($this->baseUrl.'/api/v3/secrets/raw', [
+ 'workspaceId' => $projectId,
+ 'environment' => $environment,
+ 'secretPath' => $secretPath,
+ ]);
+ }
+
+ if (! $response->successful()) {
+ throw new \RuntimeException('Infisical API error: '.($response->json('message') ?? 'HTTP '.$response->status()));
+ }
+
+ return collect($response->json('secrets', []))
+ ->mapWithKeys(fn ($secret) => [(string) data_get($secret, 'secretKey') => (string) data_get($secret, 'secretValue', '')])
+ ->all();
+ }
+
+ private function login(): string
+ {
+ $response = $this->client()->post($this->baseUrl.'/api/v1/auth/universal-auth/login', [
+ 'clientId' => $this->clientId,
+ 'clientSecret' => $this->clientSecret,
+ ]);
+
+ $accessToken = $response->json('accessToken');
+ if (! $response->successful() || blank($accessToken)) {
+ throw new \RuntimeException('Infisical login failed: '.($response->json('message') ?? 'HTTP '.$response->status()));
+ }
+
+ return $accessToken;
+ }
+
+ private function client(): PendingRequest
+ {
+ return Http::acceptJson()
+ ->withOptions($this->httpClientOptions)
+ ->connectTimeout(5)
+ ->timeout(10);
+ }
+}
diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php
new file mode 100644
index 0000000000..6033ce98f7
--- /dev/null
+++ b/app/Services/IntegrationTokenValidator.php
@@ -0,0 +1,39 @@
+ app(CloudflareTokenValidator::class)->validate($token, $capabilities),
+ 'doppler' => (new DopplerService($token))->validate(),
+ 'infisical' => (new InfisicalService(
+ (string) data_get($metadata, 'base_url', 'https://app.infisical.com'),
+ (string) data_get($metadata, 'client_id'),
+ $token,
+ ))->validate(),
+ 'vault' => (new VaultService(
+ (string) data_get($metadata, 'base_url'),
+ $token,
+ data_get($metadata, 'namespace'),
+ ))->validate(),
+ default => false,
+ };
+ }
+
+ public function errorMessage(string $provider): string
+ {
+ return match ($provider) {
+ 'cloudflare' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.',
+ 'doppler' => 'The Doppler token could not be verified. Check the token and its access.',
+ 'infisical' => 'Infisical login failed. Check the base URL, the client ID, and the client secret.',
+ 'vault' => 'The Vault token could not be verified. Check the base URL, the namespace, and the token.',
+ default => 'The token could not be verified.',
+ };
+ }
+}
diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php
new file mode 100644
index 0000000000..3cd7eabe6c
--- /dev/null
+++ b/app/Services/SentinelTrafficClient.php
@@ -0,0 +1,484 @@
+ */
+ private const ALLOWED_DIMENSIONS = [
+ 'status', 'method', 'country', 'referer', 'browser', 'os', 'device', 'protocol', 'scheme', 'tls', 'cache', 'bot', 'agent', 'ip', 'useragent',
+ ];
+
+ public function __construct(protected Server $server) {}
+
+ // NOTE: Sentinel's traffic API expects `from`/`to` as ISO-8601 Zulu strings
+ // (e.g. "2024-01-14T10:00:00Z"), confirmed against sentinel/API.md.
+ public function overview(?string $appKey, string $from, string $to): TrafficOverviewData
+ {
+ $json = json_decode($this->raw($this->overviewUrl($appKey, $from, $to)), true) ?? [];
+
+ return TrafficOverviewData::fromSentinel($json);
+ }
+
+ /**
+ * Convert a UI range key (24h/7d/30d) into ISO-8601 Zulu from/to bounds.
+ *
+ * @return array{0: string, 1: string}
+ */
+ public static function rangeWindow(string $range): array
+ {
+ $to = now();
+ $from = match ($range) {
+ '7d' => now()->subDays(7),
+ '30d' => now()->subDays(30),
+ default => now()->subDay(),
+ };
+
+ return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
+ }
+
+ /**
+ * Slim shared fetch for a single application's overview over a UI range, so the
+ * General-page widget and the full analytics tab don't duplicate window + client calls.
+ */
+ public function appOverview(string $appKey, string $range = '24h'): TrafficOverviewData
+ {
+ [$from, $to] = self::rangeWindow($range);
+
+ return $this->overview($appKey, $from, $to);
+ }
+
+ public function paths(?string $appKey, string $from, string $to, int $limit = 50): Collection
+ {
+ $rows = json_decode($this->raw($this->pathsUrl($appKey, $from, $to, $limit)), true) ?? [];
+
+ return collect($rows)->map(fn ($r) => TrafficPathData::fromSentinel($r));
+ }
+
+ public function breakdown(?string $appKey, string $dimension, string $from, string $to, int $limit = 50): Collection
+ {
+ $rows = json_decode($this->raw($this->breakdownUrl($appKey, $dimension, $from, $to, $limit)), true) ?? [];
+
+ return collect($rows)->map(fn ($r) => TrafficBreakdownData::fromSentinel($r));
+ }
+
+ /**
+ * Per-bucket status-class time series for the stacked-area chart.
+ *
+ * The series endpoints take a single `range` knob (24h/7d/30d) rather than
+ * from/to, and always return a fixed-length, zero-filled array when present.
+ * An older Sentinel without the route answers 404 (empty/non-array body);
+ * we return an empty collection in that case so callers can gracefully fall
+ * back to the donut instead of surfacing an error.
+ *
+ * @return Collection
+ */
+ public function series(?string $appKey, string $range = '24h'): Collection
+ {
+ $rows = json_decode($this->raw($this->seriesUrl($appKey, $range)), true);
+
+ if (! is_array($rows) || $rows === []) {
+ return collect();
+ }
+
+ return collect($rows)->map(fn ($r) => TrafficSeriesBucketData::fromSentinel($r));
+ }
+
+ public function apps(): array
+ {
+ return json_decode($this->raw($this->appsUrl()), true) ?? [];
+ }
+
+ public function attribution(): ?string
+ {
+ $json = json_decode($this->raw($this->attributionUrl()), true) ?? [];
+
+ return data_get($json, 'attribution');
+ }
+
+ /**
+ * Warm the 60s response cache for every endpoint the dashboard reads, in as few SSH
+ * round-trips as possible. Prefers Sentinel's aggregate `/traffic/dashboard` (one call
+ * that returns every shape, including the per-app leaderboard), and falls back to a
+ * single batched `docker exec` over the individual endpoints when that route is absent
+ * (older Sentinel). Best-effort: any failure leaves the per-call methods to fetch
+ * individually. Returns the recorded app uuids so the caller can warm the per-app
+ * overviews when the fallback path is taken.
+ *
+ * @param array $dimensions
+ * @return array
+ */
+ public function prefetchServerWide(?string $appKey, string $from, string $to, array $dimensions, string $range, int $pathLimit = 50, int $breakdownLimit = 50, int $appsLimit = 200): array
+ {
+ $bundle = $this->fetchDashboard($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit);
+ if ($bundle !== null) {
+ $this->seedFromDashboard($appKey, $from, $to, $range, $dimensions, $pathLimit, $breakdownLimit, $bundle);
+
+ if ($appKey !== null) {
+ return [];
+ }
+
+ return array_values(array_filter(
+ array_map(fn ($app) => is_array($app) ? ($app['uuid'] ?? null) : null, $bundle['apps'] ?? []),
+ fn ($uuid) => is_string($uuid) && $uuid !== ''
+ ));
+ }
+
+ // Fallback for older Sentinel without /traffic/dashboard: batch the individual endpoints.
+ $urls = [
+ $this->overviewUrl($appKey, $from, $to),
+ $this->pathsUrl($appKey, $from, $to, $pathLimit),
+ $this->seriesUrl($appKey, $range),
+ $this->attributionUrl(),
+ ];
+ foreach ($dimensions as $dimension) {
+ $urls[] = $this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit);
+ }
+ // The per-application leaderboard only exists on the unfiltered view.
+ if ($appKey === null) {
+ $urls[] = $this->appsUrl();
+ }
+
+ $this->warm($urls);
+
+ if ($appKey !== null) {
+ return [];
+ }
+
+ return array_values(array_filter(
+ $this->apps(),
+ fn ($uuid) => is_string($uuid) && $uuid !== ''
+ ));
+ }
+
+ /**
+ * Fetch Sentinel's aggregate dashboard bundle, or null when the route is absent (older
+ * Sentinel 404s) or the response isn't a real bundle. The bundle always carries an
+ * `overview` member β even for an empty range β so its presence distinguishes a genuine
+ * response from a stub/`{}`.
+ *
+ * @return array|null
+ */
+ private function fetchDashboard(?string $appKey, string $from, string $to, string $range, int $pathLimit, int $breakdownLimit, int $appsLimit): ?array
+ {
+ // Older Sentinel 404s this route. raw() throws on that (and doesn't cache the failure),
+ // so without a marker every refresh would re-probe over SSH before falling back to the
+ // batch. Remember the absence for the same 60s window as the data cache: at most one
+ // wasted probe per minute, and a Sentinel upgrade is picked up on the next window.
+ $absenceKey = 'traffic:dashboard-absent:'.$this->server->uuid;
+ if (Cache::get($absenceKey) === true) {
+ return null;
+ }
+
+ try {
+ $decoded = json_decode($this->raw($this->dashboardUrl($appKey, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit)), true);
+ } catch (\Throwable) {
+ Cache::put($absenceKey, true, 60);
+
+ return null;
+ }
+
+ if (! is_array($decoded) || ! array_key_exists('overview', $decoded)) {
+ Cache::put($absenceKey, true, 60);
+
+ return null;
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * Decompose the aggregate bundle back into the per-endpoint response cache, so the
+ * existing per-call methods (overview/paths/breakdown/series/attribution and each
+ * leaderboard app's overview) read it as a cache hit β the whole page from one fetch.
+ *
+ * @param array $dimensions
+ * @param array $bundle
+ */
+ private function seedFromDashboard(?string $appKey, string $from, string $to, string $range, array $dimensions, int $pathLimit, int $breakdownLimit, array $bundle): void
+ {
+ $put = fn (string $url, $member) => Cache::put($this->cacheKey($url), json_encode($member), 60);
+
+ $put($this->overviewUrl($appKey, $from, $to), $bundle['overview'] ?? []);
+ $put($this->pathsUrl($appKey, $from, $to, $pathLimit), $bundle['paths'] ?? []);
+ $put($this->seriesUrl($appKey, $range), $bundle['series'] ?? []);
+ $put($this->attributionUrl(), ['attribution' => $bundle['attribution'] ?? null]);
+
+ $breakdowns = $bundle['breakdowns'] ?? [];
+ foreach ($dimensions as $dimension) {
+ $put($this->breakdownUrl($appKey, $dimension, $from, $to, $breakdownLimit), $breakdowns[$dimension] ?? []);
+ }
+
+ foreach ($bundle['apps'] ?? [] as $app) {
+ $uuid = is_array($app) ? ($app['uuid'] ?? null) : null;
+ if (is_string($uuid) && $uuid !== '' && isset($app['overview'])) {
+ $put($this->overviewUrl($uuid, $from, $to), $app['overview']);
+ }
+ }
+ }
+
+ /**
+ * Warm the per-app overview cache for the leaderboard in one batched exec.
+ *
+ * @param array $appKeys
+ */
+ public function prefetchAppOverviews(array $appKeys, string $from, string $to): void
+ {
+ $urls = array_map(fn ($appKey) => $this->overviewUrl($appKey, $from, $to), $appKeys);
+
+ $this->warm($urls);
+ }
+
+ private function overviewUrl(?string $appKey, string $from, string $to): string
+ {
+ $path = $this->appScopedPath($appKey, 'overview');
+
+ return $this->url($path, ['from' => $from, 'to' => $to]);
+ }
+
+ private function pathsUrl(?string $appKey, string $from, string $to, int $limit): string
+ {
+ $path = $this->appScopedPath($appKey, 'paths');
+
+ return $this->url($path, ['from' => $from, 'to' => $to, 'limit' => (int) $limit]);
+ }
+
+ private function breakdownUrl(?string $appKey, string $dimension, string $from, string $to, int $limit): string
+ {
+ $this->assertSafeDimension($dimension);
+ $path = $this->appScopedPath($appKey, "breakdown/{$dimension}");
+
+ return $this->url($path, ['from' => $from, 'to' => $to, 'limit' => (int) $limit]);
+ }
+
+ private function seriesUrl(?string $appKey, string $range): string
+ {
+ $range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
+ $path = $this->appScopedPath($appKey, 'series');
+
+ return $this->url($path, ['range' => $range]);
+ }
+
+ private function dashboardUrl(?string $appKey, string $from, string $to, string $range, int $pathLimit, int $breakdownLimit, int $appsLimit): string
+ {
+ $range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
+ $query = [
+ 'from' => $from,
+ 'to' => $to,
+ 'range' => $range,
+ 'paths_limit' => (int) $pathLimit,
+ 'breakdown_limit' => (int) $breakdownLimit,
+ ];
+ if ($appKey === null) {
+ // apps_limit only applies to the server-wide leaderboard.
+ $query['apps_limit'] = (int) $appsLimit;
+
+ return $this->url('/traffic/dashboard', $query);
+ }
+ $this->assertSafeKey($appKey);
+
+ return $this->url("/app/{$appKey}/traffic/dashboard", $query);
+ }
+
+ private function appsUrl(): string
+ {
+ return $this->url('/traffic/apps');
+ }
+
+ private function attributionUrl(): string
+ {
+ return $this->url('/traffic/attribution');
+ }
+
+ /**
+ * Build a traffic path, optionally scoped to a single (validated) app key.
+ */
+ private function appScopedPath(?string $appKey, string $suffix): string
+ {
+ if ($appKey === null) {
+ return "/traffic/{$suffix}";
+ }
+ $this->assertSafeKey($appKey);
+
+ return "/app/{$appKey}/traffic/{$suffix}";
+ }
+
+ /**
+ * Reject anything that isn't a bare CUID2/UUID or hostname before it is
+ * interpolated into a shell-quoted `docker exec ... curl` command
+ * (see remoteFetch()/buildFetchCommand()). No quotes, spaces, slashes, or
+ * shell metacharacters.
+ */
+ private function assertSafeKey(string $value): void
+ {
+ if ($value === '' || ! preg_match('/\A[A-Za-z0-9._:-]+\z/', $value)) {
+ throw new \InvalidArgumentException('Invalid traffic analytics app key.');
+ }
+ }
+
+ private function assertSafeDimension(string $dimension): void
+ {
+ if (! in_array($dimension, self::ALLOWED_DIMENSIONS, true)) {
+ throw new \InvalidArgumentException('Invalid traffic analytics dimension.');
+ }
+ }
+
+ private function url(string $path, array $query = []): string
+ {
+ // Colons in ISO-8601 Zulu timestamps are safe in a query string; keep them
+ // unencoded to match Sentinel's expected `from`/`to` format.
+ $qs = empty($query) ? '' : '?'.str_replace('%3A', ':', http_build_query($query));
+
+ return $this->base.$path.$qs;
+ }
+
+ private function cacheKey(string $url): string
+ {
+ return 'traffic:'.$this->server->uuid.':'.md5($url);
+ }
+
+ /**
+ * True when warm() may issue its batched exec: either raw() is the base (real transport),
+ * or a subclass has explicitly overridden batchRemoteFetch to intercept the batch. A fake
+ * that only overrides raw() returns false, so warm() stays off the wire.
+ */
+ private function usesBatchableTransport(): bool
+ {
+ if ((new \ReflectionMethod($this, 'raw'))->getDeclaringClass()->getName() === self::class) {
+ return true;
+ }
+
+ return (new \ReflectionMethod($this, 'batchRemoteFetch'))->getDeclaringClass()->getName() !== self::class;
+ }
+
+ protected function raw(string $url): string
+ {
+ return Cache::remember($this->cacheKey($url), 60, fn () => $this->guard($this->remoteFetch($url)));
+ }
+
+ /**
+ * Fetch several URLs in one `docker exec` and warm each one's response cache under the
+ * same key raw() reads, so the subsequent per-call methods become cache hits. Cache hits
+ * are skipped, individual error/invalid responses are left uncached (the per-call fetch
+ * surfaces them), and any transport failure is swallowed β warming is an optimization,
+ * never a correctness dependency.
+ *
+ * @param array $urls
+ */
+ protected function warm(array $urls): void
+ {
+ // Batching only helps when raw() uses the real remote transport. A subclass that
+ // overrides raw() to serve canned bodies (a test fake) β but not batchRemoteFetch β
+ // would otherwise reach real SSH here; skip and let its raw() answer each call.
+ if (! $this->usesBatchableTransport()) {
+ return;
+ }
+
+ $misses = array_values(array_filter($urls, fn ($url) => ! Cache::has($this->cacheKey($url))));
+ if ($misses === []) {
+ return;
+ }
+
+ try {
+ $output = $this->batchRemoteFetch($misses);
+ } catch (\Throwable) {
+ return;
+ }
+
+ $bodies = explode(self::RECORD_SEPARATOR, $output);
+ foreach ($misses as $index => $url) {
+ $body = $bodies[$index] ?? '';
+ try {
+ Cache::put($this->cacheKey($url), $this->guard($body), 60);
+ } catch (\Throwable) {
+ // Invalid/error body: leave uncached so raw() re-fetches and reports it.
+ }
+ }
+ }
+
+ protected function remoteFetch(string $url): string
+ {
+ $token = $this->server->settings->ensureValidSentinelToken();
+
+ return instant_remote_process(
+ [$this->buildFetchCommand($token, $url)],
+ $this->server,
+ false
+ );
+ }
+
+ /**
+ * @param array $urls
+ */
+ protected function batchRemoteFetch(array $urls): string
+ {
+ $token = $this->server->settings->ensureValidSentinelToken();
+
+ return instant_remote_process(
+ [$this->buildBatchCommand($token, $urls)],
+ $this->server,
+ false
+ );
+ }
+
+ /**
+ * Build the `docker exec ... curl` command run inside the Sentinel container.
+ *
+ * The URL is double-quoted inside the inner `sh -c` string so the literal `&`
+ * between the `from`/`to` (and `limit`) query params is not interpreted as a
+ * shell background operator β which would background curl after `from=...` and
+ * truncate every multi-param request. The app key and dimension are validated
+ * (assertSafeKey/assertSafeDimension) before reaching here, so the URL cannot
+ * contain shell metacharacters that break out of the quoting.
+ */
+ protected function buildFetchCommand(string $token, string $url): string
+ {
+ return "docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" \"{$url}\"'";
+ }
+
+ /**
+ * Build one `docker exec` that curls every URL in order and separates the responses
+ * with a 0x1E record separator, so warm() can split them back apart. escapeshellarg
+ * safely wraps the whole script; each URL stays double-quoted so its `&` is literal.
+ *
+ * @param array $urls
+ */
+ protected function buildBatchCommand(string $token, array $urls): string
+ {
+ $script = implode(' ; ', array_map(
+ fn ($url) => "curl -s -H \"Authorization: Bearer {$token}\" \"{$url}\" ; printf '\\036'",
+ $urls
+ ));
+
+ return 'docker exec coolify-sentinel sh -c '.escapeshellarg($script);
+ }
+
+ private function guard(string $response): string
+ {
+ $payload = json_decode($response, true);
+
+ if (! is_array($payload)) {
+ throw new \RuntimeException('Traffic analytics returned an invalid response.');
+ }
+
+ if (array_key_exists('error', $payload)) {
+ $error = data_get($payload, 'error');
+ throw new \RuntimeException(is_string($error) ? $error : 'Traffic analytics request failed.');
+ }
+
+ return $response;
+ }
+}
diff --git a/app/Services/TrafficAnalyticsAggregator.php b/app/Services/TrafficAnalyticsAggregator.php
new file mode 100644
index 0000000000..6e057a3816
--- /dev/null
+++ b/app/Services/TrafficAnalyticsAggregator.php
@@ -0,0 +1,39 @@
+ $overviews
+ * @return array{overview: TrafficOverviewData, latencyApproximate: bool, uniquesApproximate: bool}
+ */
+ public static function sumOverviews(array $overviews): array
+ {
+ $multi = count($overviews) > 1;
+ $sum = fn (string $prop) => array_sum(array_map(fn ($o) => $o->{$prop}, $overviews));
+ $max = fn (string $prop) => empty($overviews) ? 0.0 : max(array_map(fn ($o) => $o->{$prop}, $overviews));
+
+ $overview = new TrafficOverviewData(
+ requests: $sum('requests'),
+ bytesIn: $sum('bytesIn'),
+ bytesOut: $sum('bytesOut'),
+ s2xx: $sum('s2xx'),
+ s3xx: $sum('s3xx'),
+ s4xx: $sum('s4xx'),
+ s5xx: $sum('s5xx'),
+ latencyP50: (float) $max('latencyP50'),
+ latencyP95: (float) $max('latencyP95'),
+ latencyP99: (float) $max('latencyP99'),
+ uniqueVisitors: $sum('uniqueVisitors'),
+ );
+
+ return [
+ 'overview' => $overview,
+ 'latencyApproximate' => $multi,
+ 'uniquesApproximate' => $multi,
+ ];
+ }
+}
diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php
new file mode 100644
index 0000000000..e41652cd54
--- /dev/null
+++ b/app/Services/VaultService.php
@@ -0,0 +1,68 @@
+ */
+ private array $httpClientOptions;
+
+ public function __construct(string $baseUrl, private string $token, private ?string $namespace = null)
+ {
+ $this->baseUrl = rtrim($baseUrl, '/');
+ Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate();
+ $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl);
+ }
+
+ public function validate(): bool
+ {
+ try {
+ return $this->client()->get($this->baseUrl.'/v1/auth/token/lookup-self')->successful();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * Read a KV v2 secret. Non-string values are stored as JSON strings.
+ *
+ * @return array
+ */
+ public function fetchSecrets(string $mount, string $path): array
+ {
+ $mount = trim($mount, '/');
+ $path = trim($path, '/');
+
+ $response = $this->client()->get($this->baseUrl."/v1/{$mount}/data/{$path}");
+
+ if (! $response->successful()) {
+ throw new \RuntimeException('Vault API error: '.($response->json('errors.0') ?? 'HTTP '.$response->status()));
+ }
+
+ return collect($response->json('data.data', []))
+ ->map(fn ($value) => is_string($value) ? $value : json_encode($value))
+ ->all();
+ }
+
+ private function client(): PendingRequest
+ {
+ $client = Http::withHeaders(['X-Vault-Token' => $this->token])
+ ->acceptJson()
+ ->withOptions($this->httpClientOptions)
+ ->connectTimeout(5)
+ ->timeout(10);
+
+ if (filled($this->namespace)) {
+ $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]);
+ }
+
+ return $client;
+ }
+}
diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php
new file mode 100644
index 0000000000..311a4634a5
--- /dev/null
+++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php
@@ -0,0 +1,111 @@
+databaseType($resource)) {
+ 'postgresql' => $dumpAll
+ ? '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}} && (gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'
+ : 'pg_restore --exit-on-error'.($replaceExisting ? ' --clean --if-exists' : '').' -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} '.$path,
+ 'mysql' => $dumpAll
+ ? $this->mysqlDumpAll('mysql', 'MYSQL', $path)
+ : '(gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE',
+ 'mariadb' => $dumpAll
+ ? $this->mysqlDumpAll('mariadb', 'MARIADB', $path)
+ : '(gunzip -cf '.$path.' 2>/dev/null || cat '.$path.') | mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE',
+ 'mongodb' => 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='.$path,
+ default => throw new InvalidArgumentException('Database import is not supported for this database type.'),
+ };
+ }
+
+ public function buildPostgresRestoreScanScript(object $resource, string $path): ?string
+ {
+ if ($this->databaseType($resource) !== 'postgresql') {
+ return null;
+ }
+
+ $escapedPath = escapeshellarg($path);
+
+ // Token separator PostgreSQL treats as whitespace: real whitespace or a
+ // /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
+ $sep = '([[:space:]]|/\\*[^*]*\\*/)';
+
+ $sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program";
+ $psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)";
+ $escapedSqlPattern = escapeshellarg($sqlPattern);
+ $escapedPsqlPattern = escapeshellarg($psqlPattern);
+ $contents = "{ gunzip -cf {$escapedPath} 2>/dev/null || cat {$escapedPath}; }";
+ $scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}";
+ $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null');
+ $sqlScan = $scan($contents);
+ $blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1';
+ $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1';
+
+ return << "\$inspect"; then
+ {$blockedInspect}
+ fi
+ if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then
+ {$blockedInspect}
+ fi
+ if {$customScan}; then
+ {$blockedProgram}
+ fi
+elif {$sqlScan}; then
+ {$blockedProgram}
+fi
+SH;
+ }
+
+ public function buildPostgresSafetyCommand(object $resource, string $container, string $path): ?string
+ {
+ $script = $this->buildPostgresRestoreScanScript($resource, $path);
+
+ if ($script === null) {
+ return null;
+ }
+
+ return 'docker exec '.$container.' sh -c '.escapeshellarg($script);
+ }
+
+ public function supports(object $resource): bool
+ {
+ return in_array($this->databaseType($resource), ['postgresql', 'mysql', 'mariadb', 'mongodb'], true);
+ }
+
+ public function databaseType(object $resource): string
+ {
+ $class = $resource->getMorphClass();
+ $type = ($resource instanceof ServiceDatabase || str_contains(strtolower($class), 'service'))
+ ? strtolower($resource->databaseType())
+ : strtolower($class);
+
+ return match (true) {
+ str_contains($type, 'postgres') => 'postgresql',
+ str_contains($type, 'mariadb') => 'mariadb',
+ str_contains($type, 'mysql') => 'mysql',
+ str_contains($type, 'mongo') => 'mongodb',
+ default => 'unsupported',
+ };
+ }
+
+ private function mysqlDumpAll(string $binary, string $prefix, string $path): string
+ {
+ $rootPassword = '${'.$prefix.'_ROOT_PASSWORD}';
+ $database = '${'.$prefix.'_DATABASE:-default}';
+
+ return "for pid in \$({$binary} -u root -p{$rootPassword} -N -e \"SELECT id FROM information_schema.processlist WHERE user != 'root';\"); do {$binary} -u root -p{$rootPassword} -e \"KILL \$pid\" 2>/dev/null || true; done && {$binary} -u root -p{$rootPassword} -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');\" | {$binary} -u root -p{$rootPassword} && {$binary} -u root -p{$rootPassword} -e \"CREATE DATABASE IF NOT EXISTS \\`{$database}\\`;\" && (gunzip -cf {$path} 2>/dev/null || cat {$path}) | {$binary} -u root -p{$rootPassword} {$database}";
+ }
+}
diff --git a/app/Support/DatabaseImport/DatabaseImportException.php b/app/Support/DatabaseImport/DatabaseImportException.php
new file mode 100644
index 0000000000..aeef2067ba
--- /dev/null
+++ b/app/Support/DatabaseImport/DatabaseImportException.php
@@ -0,0 +1,13 @@
+ Referenced secret key names (unique, in order of appearance)
+ */
+ public static function referencedKeys(?string $value): array
+ {
+ if (blank($value)) {
+ return [];
+ }
+
+ preg_match_all(self::PATTERN, $value, $matches);
+
+ return array_values(array_unique($matches[1]));
+ }
+
+ /**
+ * Replace every reference with its value from the secrets map.
+ * Keys missing from the map are left as-is β collect them first with
+ * missingKeys() and fail before calling substitute().
+ *
+ * @param array $secrets
+ */
+ public static function substitute(string $value, array $secrets): string
+ {
+ return preg_replace_callback(
+ self::PATTERN,
+ fn (array $matches) => array_key_exists($matches[1], $secrets) ? $secrets[$matches[1]] : $matches[0],
+ $value,
+ );
+ }
+
+ /**
+ * @param array $secrets
+ * @return list
+ */
+ public static function missingKeys(?string $value, array $secrets): array
+ {
+ return array_values(array_filter(
+ self::referencedKeys($value),
+ fn (string $key) => ! array_key_exists($key, $secrets),
+ ));
+ }
+}
diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php
new file mode 100644
index 0000000000..878d46c1e4
--- /dev/null
+++ b/app/Traits/Auditable.php
@@ -0,0 +1,102 @@
+ $model->recordAuditMutation('created'));
+ static::updated(fn (Model $model) => $model->recordAuditMutation('updated'));
+ static::deleted(fn (Model $model) => $model->recordAuditMutation('deleted'));
+ }
+
+ private function recordAuditMutation(string $action): void
+ {
+ if (! $this->auditLoggingEnabled || ! auth()->check()) {
+ return;
+ }
+
+ $teamId = $this->auditTeamId();
+ if ($teamId === null) {
+ return;
+ }
+
+ $changedFields = $action === 'updated'
+ ? collect(array_keys($this->getChanges()))
+ ->reject(fn (string $field): bool => in_array($field, [
+ 'updated_at',
+ 'order',
+ 'status',
+ ...($this->auditExclude ?? []),
+ ], true))
+ ->values()
+ ->all()
+ : [];
+
+ if ($action === 'updated' && $changedFields === []) {
+ return;
+ }
+
+ $resourceType = Str::snake(class_basename($this));
+ $source = auth()->user()?->currentAccessToken() instanceof PersonalAccessToken ? 'api' : 'ui';
+
+ auditLog("{$source}.{$resourceType}.{$action}", [
+ 'team_id' => $teamId,
+ "{$resourceType}_uuid" => $this->getAttribute('uuid'),
+ "{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'),
+ 'changed_fields' => $changedFields,
+ ]);
+ }
+
+ public function withoutAuditLogging(Closure $callback): mixed
+ {
+ $wasAuditLoggingEnabled = $this->auditLoggingEnabled;
+ $this->auditLoggingEnabled = false;
+
+ try {
+ return $callback();
+ } finally {
+ $this->auditLoggingEnabled = $wasAuditLoggingEnabled;
+ }
+ }
+
+ private function auditTeamId(): ?int
+ {
+ if ($this instanceof Team) {
+ return (int) $this->getKey();
+ }
+
+ if ($this->getAttribute('team_id') !== null) {
+ return (int) $this->getAttribute('team_id');
+ }
+
+ if ($this->getAttribute('project_id') !== null) {
+ return $this->project?->team_id;
+ }
+
+ if ($this->getAttribute('environment_id') !== null) {
+ return $this->environment?->project?->team_id;
+ }
+
+ if ($this->getAttribute('server_id') !== null) {
+ return $this->server?->team_id;
+ }
+
+ if ($this->getAttribute('resourceable_id') !== null) {
+ return $this->resourceable?->team()?->id
+ ?? $this->resourceable?->team_id
+ ?? $this->resourceable?->environment?->project?->team_id;
+ }
+
+ return null;
+ }
+}
diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
index a2c3d06da9..b8ff5df14b 100644
--- a/app/Traits/ExecuteRemoteCommand.php
+++ b/app/Traits/ExecuteRemoteCommand.php
@@ -46,6 +46,13 @@ trait ExecuteRemoteCommand
);
}
+ if (isset($this->remote_secrets_cache)) {
+ $lockedVars = $lockedVars->merge(array_values(array_filter(
+ $this->remote_secrets_cache,
+ static fn (mixed $value): bool => is_string($value) && $value !== ''
+ )));
+ }
+
foreach ($lockedVars as $key => $value) {
$escapedValue = preg_quote($value, '/');
$text = preg_replace(
diff --git a/app/Traits/ExecutesDatabaseStartCommands.php b/app/Traits/ExecutesDatabaseStartCommands.php
new file mode 100644
index 0000000000..d267a8b1b2
--- /dev/null
+++ b/app/Traits/ExecutesDatabaseStartCommands.php
@@ -0,0 +1,19 @@
+execute($commands, $database, $activity);
+ }
+
+ return remote_process($commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+ }
+}
diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php
new file mode 100644
index 0000000000..8b3e50b7bd
--- /dev/null
+++ b/app/Traits/HasSecretManager.php
@@ -0,0 +1,107 @@
+|null */
+ private ?array $resolvedSecretManagerValues = null;
+
+ public static function bootHasSecretManager(): void
+ {
+ static::deleting(fn ($resource) => $resource->secretManagerLink()->delete());
+ }
+
+ public function secretManagerLink(): MorphOne
+ {
+ return $this->morphOne(SecretManagerLink::class, 'resourceable');
+ }
+
+ public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string
+ {
+ $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
+
+ return $this->formatEnvironmentVariableValue($environmentVariable, $value);
+ }
+
+ public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
+ return $value;
+ }
+
+ return $environmentVariable->is_literal || $environmentVariable->is_multiline
+ ? "'{$value}'"
+ : escapeEnvVariables($value);
+ }
+
+ public function resolveSecretManagerEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string
+ {
+ $value = $this->resolvedEnvironmentVariableValue($environmentVariable);
+
+ if ($value === null) {
+ return null;
+ }
+
+ if (RemoteSecretReferences::containsReference($value)) {
+ $secrets = $this->secretManagerValues();
+ $missing = RemoteSecretReferences::missingKeys($value, $secrets);
+
+ if ($missing !== []) {
+ throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key}).");
+ }
+
+ $value = RemoteSecretReferences::substitute($value, $secrets);
+ }
+
+ return $value;
+ }
+
+ public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool
+ {
+ return RemoteSecretReferences::containsReference(
+ $this->resolvedEnvironmentVariableValue($environmentVariable),
+ );
+ }
+
+ private function resolvedEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string
+ {
+ return $environmentVariable->get_real_environment_variables_with_server(
+ $environmentVariable->value,
+ $this,
+ data_get($this, 'server'),
+ );
+ }
+
+ /** @return array */
+ private function secretManagerValues(): array
+ {
+ if ($this->resolvedSecretManagerValues !== null) {
+ return $this->resolvedSecretManagerValues;
+ }
+
+ $link = $this->secretManagerLink()->with('integrationToken')->first();
+
+ if (! $link) {
+ throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.');
+ }
+
+ return $this->resolvedSecretManagerValues = $link->fetchSecrets();
+ }
+
+ /** @return array */
+ public function resolvedSecretManagerValuesForRedaction(): array
+ {
+ return $this->resolvedSecretManagerValues ?? [];
+ }
+}
diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php
new file mode 100644
index 0000000000..1b46ca2dd5
--- /dev/null
+++ b/app/Traits/HasSecretManagerAutocomplete.php
@@ -0,0 +1,58 @@
+secretManagerLinkForAutocomplete() !== null;
+ }
+
+ /**
+ * @return list
+ */
+ public function fetchSecretManagerKeys(): array
+ {
+ $this->skipRender();
+
+ $link = $this->secretManagerLinkForAutocomplete();
+
+ if (! $link) {
+ return [];
+ }
+
+ try {
+ $this->authorize('view', $link->resourceable);
+ $keys = array_keys($link->fetchSecrets());
+ sort($keys);
+
+ return $keys;
+ } catch (\Throwable) {
+ throw new \RuntimeException('Unable to fetch secret manager keys.');
+ }
+ }
+
+ private function secretManagerLinkForAutocomplete(): ?SecretManagerLink
+ {
+ $resource = $this->secretManagerResource();
+
+ if (! $resource || ! method_exists($resource, 'secretManagerLink')) {
+ return null;
+ }
+
+ if (! $resource->relationLoaded('secretManagerLink')) {
+ $resource->load('secretManagerLink.integrationToken');
+ }
+
+ return $resource->secretManagerLink;
+ }
+}
diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php
index a3e6646fec..9ff5d72dc5 100644
--- a/app/View/Components/Forms/EnvVarInput.php
+++ b/app/View/Components/Forms/EnvVarInput.php
@@ -35,6 +35,7 @@ class EnvVarInput extends Component
public mixed $canResource = null,
public bool $autoDisable = true,
public array $availableVars = [],
+ public bool $hasVaultSource = false,
public ?string $projectUuid = null,
public ?string $environmentUuid = null,
public ?string $serverUuid = null,
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index b8001497ba..b32870a5f7 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -4,6 +4,7 @@ use App\Actions\Shared\MigrateResourceToDestination;
use App\Enums\BuildPackTypes;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
+use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
@@ -141,6 +142,7 @@ function sharedDataApplications()
'gpu_options' => 'string|nullable',
'is_consistent_container_name_enabled' => 'boolean',
'custom_internal_name' => 'string|nullable',
+ 'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH,
'preview_url_template' => 'string',
'max_restart_count' => 'integer|min:0',
'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS,
@@ -408,6 +410,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('gpu_options');
$request->offsetUnset('is_consistent_container_name_enabled');
$request->offsetUnset('custom_internal_name');
+ $request->offsetUnset('custom_container_name_prefix');
$request->offsetUnset('docker_compose_raw');
$request->offsetUnset('tags');
}
diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
index 339a0bcf7b..2fb0bb3f53 100644
--- a/bootstrap/helpers/applications.php
+++ b/bootstrap/helpers/applications.php
@@ -84,6 +84,15 @@ function queue_application_deployment(Application $application, string $deployme
'only_this_server' => $only_this_server,
]);
+ if (auth()->check() && ! $is_webhook && ! $is_api && ! $rollback) {
+ auditLog($restart_only ? 'ui.application.restarted' : 'ui.application.deployed', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid,
+ 'force_rebuild' => $force_rebuild,
+ ]);
+ }
+
if ($no_questions_asked) {
$deployment->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php
index 8477450c4b..1a1ad0a994 100644
--- a/bootstrap/helpers/audit.php
+++ b/bootstrap/helpers/audit.php
@@ -1,13 +1,10 @@
$context Identifiers + outcome details.
@@ -16,39 +13,15 @@ if (! function_exists('auditLog')) {
function auditLog(string $event, array $context = [], string $level = 'info'): void
{
try {
- $request = app()->bound('request') ? request() : null;
- $user = auth()->check() ? auth()->user() : null;
- $token = $user?->currentAccessToken();
-
- $base = [
- 'event' => $event,
- 'ip' => $request?->ip(),
- 'ua' => substr((string) $request?->userAgent(), 0, 200),
- 'user_id' => $user?->id,
- 'user_email' => $user?->email,
- 'team_id' => $token ? data_get($token, 'team_id') : null,
- 'token_id' => $token?->id ?? null,
- 'token_name' => $token?->name ?? null,
- 'method' => $request?->method(),
- 'path' => $request?->path(),
- ];
-
- $payload = array_merge($base, $context);
-
- Log::channel('audit')->{$level}($event, $payload);
- } catch (Throwable $e) {
- // Audit logging must never break the request path.
- try {
- Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]);
- } catch (Throwable) {
- }
+ AuditEvent::record($event, $context);
+ } catch (Throwable) {
}
}
}
if (! function_exists('auditLogWebhookFailure')) {
/**
- * Record a webhook signature/auth verification failure to the `audit` channel.
+ * Record a webhook signature/auth verification failure.
*/
function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void
{
@@ -58,10 +31,7 @@ if (! function_exists('auditLogWebhookFailure')) {
$event = "webhook.{$provider}.signature_failed";
$base = [
- 'event' => $event,
'reason' => $reason,
- 'ip' => $request?->ip(),
- 'ua' => substr((string) $request?->userAgent(), 0, 200),
'method' => $request?->method(),
'path' => $request?->path(),
'event_header' => $request?->header('X-GitHub-Event')
@@ -70,12 +40,8 @@ if (! function_exists('auditLogWebhookFailure')) {
?? $request?->header('X-Event-Key'),
];
- Log::channel('audit')->warning($event, array_merge($base, $context));
- } catch (Throwable $e) {
- try {
- Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]);
- } catch (Throwable) {
- }
+ auditLog($event, array_merge($base, $context), 'warning');
+ } catch (Throwable) {
}
}
}
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 613a104e0e..3c87882d6f 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -349,17 +349,32 @@ function generateApplicationContainerName(Application $application, $pull_reques
// TODO: refactor generateApplicationContainerName, we do not need $application and $pull_request_id
$consistent_container_name = $application->settings->is_consistent_container_name_enabled;
- $now = now()->format('Hisu');
+ $name = $consistent_container_name ? ($application->settings->custom_internal_name ?: $application->uuid) : $application->uuid;
+ $now = now()->format('Ymd\THis');
if ($pull_request_id !== 0 && $pull_request_id !== null) {
- return $application->uuid.'-pr-'.$pull_request_id;
+ return $name.'-pr-'.$pull_request_id;
} else {
if ($consistent_container_name) {
- return $application->uuid;
+ return $name;
}
- return $application->uuid.'-'.$now;
+ return ($application->settings->custom_container_name_prefix ?: $application->uuid).'-'.$now;
}
}
+
+/**
+ * Generated (rolling update) container names end with the timestamp from generateApplicationContainerName().
+ * Drop the legacy pattern once containers created before the ISO 8601 suffix are gone.
+ */
+function isGeneratedContainerName(string $containerName): bool
+{
+ $isoTimestampSuffix = '/-\d{8}T\d{6}$/';
+ $legacyTimestampSuffix = '/-\d{12}$/';
+
+ return preg_match($isoTimestampSuffix, $containerName) === 1
+ || preg_match($legacyTimestampSuffix, $containerName) === 1;
+}
+
function get_port_from_dockerfile($dockerfile): ?int
{
$dockerfile_array = explode("\n", $dockerfile);
@@ -530,7 +545,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool
->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain));
}
-function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, array $domainPortOverrides = [])
+function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [])
{
$labels = collect([]);
if ($serviceLabels) {
@@ -596,6 +611,18 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
if ($is_http_basic_auth_enabled) {
$labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\"");
}
+ if ($is_traffic_analytics_enabled) {
+ $labels->push("caddy_{$loop}.log.output=file /traffic/access.log");
+ // Explicit lumberjack roll options so the access log doesn't grow unbounded
+ // (Caddy's defaults are undocumented). caddy-docker-proxy renders these dotted
+ // keys as a nested block: output file /traffic/access.log { roll_size 20MiB; roll_keep 5; roll_keep_for 168h }.
+ // Rotation is rename-based, which is safe for Sentinel's tailer (it reopens on inode change).
+ $labels->push("caddy_{$loop}.log.output.roll_size=20MiB");
+ $labels->push("caddy_{$loop}.log.output.roll_keep=5");
+ $labels->push("caddy_{$loop}.log.output.roll_keep_for=168h");
+ $labels->push("caddy_{$loop}.log.format=json");
+ $labels->push("caddy_{$loop}.log_append=coolify_app_id {$uuid}");
+ }
}
return $labels->sort();
@@ -968,6 +995,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
+ is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
domainPortOverrides: $application->domain_port_overrides ?? [],
));
break;
@@ -1001,6 +1029,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
+ is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
domainPortOverrides: $application->domain_port_overrides ?? [],
));
}
@@ -1045,6 +1074,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
+ is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
domainPortOverrides: $preview->domain_port_overrides ?? [],
));
break;
@@ -1076,6 +1106,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
+ is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
domainPortOverrides: $preview->domain_port_overrides ?? [],
));
}
diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
index fe639950be..c5c0c391d0 100644
--- a/bootstrap/helpers/proxy.php
+++ b/bootstrap/helpers/proxy.php
@@ -8,6 +8,29 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Yaml\Yaml;
+function traefikAccessLogCommands(bool $enabled): array
+{
+ if (! $enabled) {
+ return [];
+ }
+
+ return [
+ '--accesslog=true',
+ '--accesslog.filepath=/traefik/access.log',
+ '--accesslog.format=json',
+ '--accesslog.fields.headers.names.Cf-Connecting-Ip=keep',
+ '--accesslog.fields.headers.names.Cf-Ipcountry=keep',
+ '--accesslog.fields.headers.names.Cf-Cache-Status=keep',
+ '--accesslog.fields.headers.names.Cf-Verified-Bot=keep',
+ '--accesslog.fields.headers.names.Cf-Ray=keep',
+ // Kept so Sentinel can resolve the real client IP behind a non-Cloudflare
+ // reverse proxy (leftmost X-Forwarded-For entry) and report User-Agents/referrers.
+ '--accesslog.fields.headers.names.X-Forwarded-For=keep',
+ '--accesslog.fields.headers.names.User-Agent=keep',
+ '--accesslog.fields.headers.names.Referer=keep',
+ ];
+}
+
/**
* Check if a network name is a Docker predefined system network.
* These networks cannot be created, modified, or managed by docker network commands.
@@ -325,13 +348,17 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
if (isDev()) {
$config['services']['traefik']['command'][] = '--api.insecure=true';
$config['services']['traefik']['command'][] = '--log.level=debug';
- $config['services']['traefik']['command'][] = '--accesslog.filepath=/traefik/access.log';
$config['services']['traefik']['command'][] = '--accesslog.bufferingsize=100';
$config['services']['traefik']['volumes'][] = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/:/traefik';
} else {
$config['services']['traefik']['command'][] = '--api.insecure=false';
$config['services']['traefik']['volumes'][] = "{$proxy_path}:/traefik";
}
+ // Access logging + analytics header capture (JSON log, real-IP/UA/referrer headers)
+ // applies to both dev and production so traffic analytics can be exercised locally.
+ foreach (traefikAccessLogCommands($server->isTrafficAnalyticsEnabled()) as $cmd) {
+ $config['services']['traefik']['command'][] = $cmd;
+ }
if ($server->isSwarm()) {
data_forget($config, 'services.traefik.container_name');
data_forget($config, 'services.traefik.restart');
@@ -358,6 +385,24 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
$config['services']['traefik']['command'][] = $custom_command;
}
}
+
+ // Traefik has no native access-log rotation. Add a minimal logrotate sidecar that
+ // rotates /traefik/access.log in copytruncate mode so the file keeps the same inode
+ // and Sentinel keeps its file handle (the tailer handles len < pos by seeking to 0).
+ // Only for the non-swarm, non-dev production path (dev uses a different access-log path).
+ if ($server->isTrafficAnalyticsEnabled() && ! $server->isSwarm() && ! isDev()) {
+ $config['services']['traefik-logrotate'] = [
+ 'image' => 'alpine:3.20',
+ 'restart' => RESTART_MODE,
+ 'volumes' => [
+ "{$proxy_path}:/traefik",
+ ],
+ 'labels' => [
+ 'coolify.managed=true',
+ ],
+ 'entrypoint' => 'sh -c \'apk add --no-cache logrotate >/dev/null 2>&1; printf "/traefik/access.log {\n copytruncate\n size 20M\n rotate 5\n compress\n missingok\n notifempty\n}\n" > /etc/logrotate.d/traefik-access; while true; do logrotate -s /traefik/.logrotate.state /etc/logrotate.d/traefik-access; sleep 3600; done\'',
+ ];
+ }
} elseif ($proxy_type === 'CADDY') {
$config = [
'networks' => $array_of_networks->toArray(),
@@ -392,6 +437,9 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
],
],
];
+ if ($server->isTrafficAnalyticsEnabled()) {
+ $config['services']['caddy']['volumes'][] = "{$proxy_path}:/traffic";
+ }
} else {
return null;
}
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index d1f5e4016b..7e10ded579 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -4553,6 +4553,28 @@ function formatBytes(?int $bytes, int $precision = 2): string
return round($value, $precision).' '.$units[$exponent];
}
+/**
+ * Compact human-readable count (e.g. 26_360 -> "26.36k", 1_200_000 -> "1.2M").
+ * Trailing zeros are trimmed so round values read cleanly ("1k", not "1.00k").
+ * Used for the dense metric columns in the traffic-analytics lists.
+ */
+function compactNumber(?int $n): string
+{
+ $n = (int) $n;
+
+ if ($n < 1000) {
+ return (string) $n;
+ }
+
+ [$divisor, $suffix] = match (true) {
+ $n >= 1_000_000_000 => [1_000_000_000, 'B'],
+ $n >= 1_000_000 => [1_000_000, 'M'],
+ default => [1000, 'k'],
+ };
+
+ return rtrim(rtrim(number_format($n / $divisor, 2, '.', ''), '0'), '.').$suffix;
+}
+
/**
* Validates that a file path is safely within the /tmp/ directory.
* Protects against unsafe parent directory paths by resolving the real path
@@ -4691,7 +4713,7 @@ function formatContainerStatus(string $status): string
* Check if password confirmation should be skipped.
* Returns true if:
* - Two-step confirmation is globally disabled
- * - User has no password (OAuth users)
+ * - User has no usable local password confirmation (including SSO users)
*
* Used by modal-confirmation.blade.php to determine if password step should be shown.
*
@@ -4704,8 +4726,9 @@ function shouldSkipPasswordConfirmation(): bool
return true;
}
- // Skip if user has no password (OAuth users)
- if (! Auth::user()?->hasPassword()) {
+ // OAuth users may have an unusable generated password, so the linked
+ // identity is the source of truth for whether confirmation is possible.
+ if (! Auth::user()?->requiresPasswordConfirmation()) {
return true;
}
@@ -4716,7 +4739,7 @@ function shouldSkipPasswordConfirmation(): bool
* Verify password for two-step confirmation.
* Skips verification if:
* - Two-step confirmation is globally disabled
- * - User has no password (OAuth users)
+ * - User has no usable local password confirmation (including SSO users)
*
* @param mixed $password The password to verify (may be array if skipped by frontend)
* @param Component|null $component Optional Livewire component to add errors to
@@ -4933,3 +4956,387 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
return str($value)->value();
}
+
+/**
+ * Convert an ISO 3166-1 alpha-2 country code into its regional-indicator flag emoji.
+ *
+ * The input is case-insensitive (e.g. "us" and "US" both yield the United States flag).
+ * For null, empty, or otherwise invalid input (not exactly two ASCII letters) a neutral
+ * globe emoji is returned to represent an "Unknown" origin.
+ */
+function countryFlagEmoji(?string $a2): string
+{
+ $unknown = 'π';
+
+ if (! is_string($a2)) {
+ return $unknown;
+ }
+
+ $code = strtoupper(trim($a2));
+
+ if (preg_match('/^[A-Z]{2}$/', $code) !== 1) {
+ return $unknown;
+ }
+
+ $flag = '';
+ foreach (str_split($code) as $letter) {
+ $flag .= mb_chr(0x1F1E6 + (ord($letter) - ord('A')), 'UTF-8');
+ }
+
+ return $flag;
+}
+
+/**
+ * Resolve an ISO 3166-1 alpha-2 code to a flag image URL (flagcdn.com).
+ *
+ * Emoji flags do not render on most Linux/Windows browsers, so the analytics
+ * views render an instead. Returns null for null/invalid codes so callers
+ * can fall back to a globe icon.
+ */
+function countryFlagUrl(?string $a2, string $size = '24x18'): ?string
+{
+ if (! is_string($a2)) {
+ return null;
+ }
+
+ $code = strtolower(trim($a2));
+
+ if (preg_match('/^[a-z]{2}$/', $code) !== 1) {
+ return null;
+ }
+
+ return "https://flagcdn.com/{$size}/{$code}.png";
+}
+
+/**
+ * Extract the bare host from a referer value (full URL or bare host), dropping
+ * a leading "www.". Returns null when there is no usable host (e.g. direct hits).
+ */
+function refererHost(?string $referer): ?string
+{
+ if (! is_string($referer) || trim($referer) === '') {
+ return null;
+ }
+
+ $referer = trim($referer);
+ $withScheme = str_contains($referer, '://') ? $referer : 'http://'.$referer;
+ $host = parse_url($withScheme, PHP_URL_HOST) ?: null;
+
+ if (! $host) {
+ return null;
+ }
+
+ $host = strtolower($host);
+
+ return str_starts_with($host, 'www.') ? substr($host, 4) : $host;
+}
+
+/**
+ * Favicon URL for a host, served by DuckDuckGo's icon proxy. Used to decorate
+ * referrer rows in analytics.
+ *
+ * Note: rendering these icons makes the operator's browser request each favicon
+ * from icons.duckduckgo.com, which discloses the referrer hostnames of the
+ * operator's own traffic to that third party. Same applies to countryFlagUrl()
+ * (flagcdn.com). No API key is required.
+ */
+function refererFaviconUrl(string $host): string
+{
+ return 'https://icons.duckduckgo.com/ip3/'.rawurlencode($host).'.ico';
+}
+
+/**
+ * Map Sentinel's lowercase woothee device category to a friendly, capitalized
+ * label (e.g. "pc" -> "Desktop", "smartphone" -> "Mobile").
+ */
+function deviceLabel(?string $device): string
+{
+ $value = strtolower(trim((string) $device));
+
+ return match ($value) {
+ '' => 'Unknown',
+ 'pc' => 'Desktop',
+ 'smartphone' => 'Mobile',
+ 'mobilephone' => 'Mobile',
+ 'appliance' => 'Appliance',
+ 'crawler' => 'Bot',
+ default => Str::title($value),
+ };
+}
+
+/**
+ * Resolve an ISO 3166-1 alpha-2 country code to its English country name.
+ *
+ * Uses a bundled ISO 3166-1 lookup so the result is deterministic and does not
+ * depend on the intl extension being installed. Returns "Unknown" for null,
+ * empty, invalid, or unassigned codes.
+ */
+function countryName(?string $a2): string
+{
+ $unknown = 'Unknown';
+
+ if (! is_string($a2)) {
+ return $unknown;
+ }
+
+ $code = strtoupper(trim($a2));
+
+ if (preg_match('/^[A-Z]{2}$/', $code) !== 1) {
+ return $unknown;
+ }
+
+ static $names = [
+ 'AD' => 'Andorra',
+ 'AE' => 'United Arab Emirates',
+ 'AF' => 'Afghanistan',
+ 'AG' => 'Antigua & Barbuda',
+ 'AI' => 'Anguilla',
+ 'AL' => 'Albania',
+ 'AM' => 'Armenia',
+ 'AO' => 'Angola',
+ 'AQ' => 'Antarctica',
+ 'AR' => 'Argentina',
+ 'AS' => 'American Samoa',
+ 'AT' => 'Austria',
+ 'AU' => 'Australia',
+ 'AW' => 'Aruba',
+ 'AX' => 'Γ
land Islands',
+ 'AZ' => 'Azerbaijan',
+ 'BA' => 'Bosnia & Herzegovina',
+ 'BB' => 'Barbados',
+ 'BD' => 'Bangladesh',
+ 'BE' => 'Belgium',
+ 'BF' => 'Burkina Faso',
+ 'BG' => 'Bulgaria',
+ 'BH' => 'Bahrain',
+ 'BI' => 'Burundi',
+ 'BJ' => 'Benin',
+ 'BL' => 'St. BarthΓ©lemy',
+ 'BM' => 'Bermuda',
+ 'BN' => 'Brunei',
+ 'BO' => 'Bolivia',
+ 'BQ' => 'Caribbean Netherlands',
+ 'BR' => 'Brazil',
+ 'BS' => 'Bahamas',
+ 'BT' => 'Bhutan',
+ 'BV' => 'Bouvet Island',
+ 'BW' => 'Botswana',
+ 'BY' => 'Belarus',
+ 'BZ' => 'Belize',
+ 'CA' => 'Canada',
+ 'CC' => 'Cocos (Keeling) Islands',
+ 'CD' => 'Congo - Kinshasa',
+ 'CF' => 'Central African Republic',
+ 'CG' => 'Congo - Brazzaville',
+ 'CH' => 'Switzerland',
+ 'CI' => 'CΓ΄te dβIvoire',
+ 'CK' => 'Cook Islands',
+ 'CL' => 'Chile',
+ 'CM' => 'Cameroon',
+ 'CN' => 'China',
+ 'CO' => 'Colombia',
+ 'CR' => 'Costa Rica',
+ 'CU' => 'Cuba',
+ 'CV' => 'Cape Verde',
+ 'CW' => 'CuraΓ§ao',
+ 'CX' => 'Christmas Island',
+ 'CY' => 'Cyprus',
+ 'CZ' => 'Czechia',
+ 'DE' => 'Germany',
+ 'DJ' => 'Djibouti',
+ 'DK' => 'Denmark',
+ 'DM' => 'Dominica',
+ 'DO' => 'Dominican Republic',
+ 'DZ' => 'Algeria',
+ 'EC' => 'Ecuador',
+ 'EE' => 'Estonia',
+ 'EG' => 'Egypt',
+ 'EH' => 'Western Sahara',
+ 'ER' => 'Eritrea',
+ 'ES' => 'Spain',
+ 'ET' => 'Ethiopia',
+ 'FI' => 'Finland',
+ 'FJ' => 'Fiji',
+ 'FK' => 'Falkland Islands',
+ 'FM' => 'Micronesia',
+ 'FO' => 'Faroe Islands',
+ 'FR' => 'France',
+ 'GA' => 'Gabon',
+ 'GB' => 'United Kingdom',
+ 'GD' => 'Grenada',
+ 'GE' => 'Georgia',
+ 'GF' => 'French Guiana',
+ 'GG' => 'Guernsey',
+ 'GH' => 'Ghana',
+ 'GI' => 'Gibraltar',
+ 'GL' => 'Greenland',
+ 'GM' => 'Gambia',
+ 'GN' => 'Guinea',
+ 'GP' => 'Guadeloupe',
+ 'GQ' => 'Equatorial Guinea',
+ 'GR' => 'Greece',
+ 'GS' => 'South Georgia & South Sandwich Islands',
+ 'GT' => 'Guatemala',
+ 'GU' => 'Guam',
+ 'GW' => 'Guinea-Bissau',
+ 'GY' => 'Guyana',
+ 'HK' => 'Hong Kong SAR China',
+ 'HM' => 'Heard & McDonald Islands',
+ 'HN' => 'Honduras',
+ 'HR' => 'Croatia',
+ 'HT' => 'Haiti',
+ 'HU' => 'Hungary',
+ 'ID' => 'Indonesia',
+ 'IE' => 'Ireland',
+ 'IL' => 'Israel',
+ 'IM' => 'Isle of Man',
+ 'IN' => 'India',
+ 'IO' => 'British Indian Ocean Territory',
+ 'IQ' => 'Iraq',
+ 'IR' => 'Iran',
+ 'IS' => 'Iceland',
+ 'IT' => 'Italy',
+ 'JE' => 'Jersey',
+ 'JM' => 'Jamaica',
+ 'JO' => 'Jordan',
+ 'JP' => 'Japan',
+ 'KE' => 'Kenya',
+ 'KG' => 'Kyrgyzstan',
+ 'KH' => 'Cambodia',
+ 'KI' => 'Kiribati',
+ 'KM' => 'Comoros',
+ 'KN' => 'St. Kitts & Nevis',
+ 'KP' => 'North Korea',
+ 'KR' => 'South Korea',
+ 'KW' => 'Kuwait',
+ 'KY' => 'Cayman Islands',
+ 'KZ' => 'Kazakhstan',
+ 'LA' => 'Laos',
+ 'LB' => 'Lebanon',
+ 'LC' => 'St. Lucia',
+ 'LI' => 'Liechtenstein',
+ 'LK' => 'Sri Lanka',
+ 'LR' => 'Liberia',
+ 'LS' => 'Lesotho',
+ 'LT' => 'Lithuania',
+ 'LU' => 'Luxembourg',
+ 'LV' => 'Latvia',
+ 'LY' => 'Libya',
+ 'MA' => 'Morocco',
+ 'MC' => 'Monaco',
+ 'MD' => 'Moldova',
+ 'ME' => 'Montenegro',
+ 'MF' => 'St. Martin',
+ 'MG' => 'Madagascar',
+ 'MH' => 'Marshall Islands',
+ 'MK' => 'North Macedonia',
+ 'ML' => 'Mali',
+ 'MM' => 'Myanmar (Burma)',
+ 'MN' => 'Mongolia',
+ 'MO' => 'Macao SAR China',
+ 'MP' => 'Northern Mariana Islands',
+ 'MQ' => 'Martinique',
+ 'MR' => 'Mauritania',
+ 'MS' => 'Montserrat',
+ 'MT' => 'Malta',
+ 'MU' => 'Mauritius',
+ 'MV' => 'Maldives',
+ 'MW' => 'Malawi',
+ 'MX' => 'Mexico',
+ 'MY' => 'Malaysia',
+ 'MZ' => 'Mozambique',
+ 'NA' => 'Namibia',
+ 'NC' => 'New Caledonia',
+ 'NE' => 'Niger',
+ 'NF' => 'Norfolk Island',
+ 'NG' => 'Nigeria',
+ 'NI' => 'Nicaragua',
+ 'NL' => 'Netherlands',
+ 'NO' => 'Norway',
+ 'NP' => 'Nepal',
+ 'NR' => 'Nauru',
+ 'NU' => 'Niue',
+ 'NZ' => 'New Zealand',
+ 'OM' => 'Oman',
+ 'PA' => 'Panama',
+ 'PE' => 'Peru',
+ 'PF' => 'French Polynesia',
+ 'PG' => 'Papua New Guinea',
+ 'PH' => 'Philippines',
+ 'PK' => 'Pakistan',
+ 'PL' => 'Poland',
+ 'PM' => 'St. Pierre & Miquelon',
+ 'PN' => 'Pitcairn Islands',
+ 'PR' => 'Puerto Rico',
+ 'PS' => 'Palestinian Territories',
+ 'PT' => 'Portugal',
+ 'PW' => 'Palau',
+ 'PY' => 'Paraguay',
+ 'QA' => 'Qatar',
+ 'RE' => 'RΓ©union',
+ 'RO' => 'Romania',
+ 'RS' => 'Serbia',
+ 'RU' => 'Russia',
+ 'RW' => 'Rwanda',
+ 'SA' => 'Saudi Arabia',
+ 'SB' => 'Solomon Islands',
+ 'SC' => 'Seychelles',
+ 'SD' => 'Sudan',
+ 'SE' => 'Sweden',
+ 'SG' => 'Singapore',
+ 'SH' => 'St. Helena',
+ 'SI' => 'Slovenia',
+ 'SJ' => 'Svalbard & Jan Mayen',
+ 'SK' => 'Slovakia',
+ 'SL' => 'Sierra Leone',
+ 'SM' => 'San Marino',
+ 'SN' => 'Senegal',
+ 'SO' => 'Somalia',
+ 'SR' => 'Suriname',
+ 'SS' => 'South Sudan',
+ 'ST' => 'SΓ£o TomΓ© & PrΓncipe',
+ 'SV' => 'El Salvador',
+ 'SX' => 'Sint Maarten',
+ 'SY' => 'Syria',
+ 'SZ' => 'Eswatini',
+ 'TC' => 'Turks & Caicos Islands',
+ 'TD' => 'Chad',
+ 'TF' => 'French Southern Territories',
+ 'TG' => 'Togo',
+ 'TH' => 'Thailand',
+ 'TJ' => 'Tajikistan',
+ 'TK' => 'Tokelau',
+ 'TL' => 'Timor-Leste',
+ 'TM' => 'Turkmenistan',
+ 'TN' => 'Tunisia',
+ 'TO' => 'Tonga',
+ 'TR' => 'TΓΌrkiye',
+ 'TT' => 'Trinidad & Tobago',
+ 'TV' => 'Tuvalu',
+ 'TW' => 'Taiwan',
+ 'TZ' => 'Tanzania',
+ 'UA' => 'Ukraine',
+ 'UG' => 'Uganda',
+ 'UM' => 'U.S. Outlying Islands',
+ 'US' => 'United States',
+ 'UY' => 'Uruguay',
+ 'UZ' => 'Uzbekistan',
+ 'VA' => 'Vatican City',
+ 'VC' => 'St. Vincent & Grenadines',
+ 'VE' => 'Venezuela',
+ 'VG' => 'British Virgin Islands',
+ 'VI' => 'U.S. Virgin Islands',
+ 'VN' => 'Vietnam',
+ 'VU' => 'Vanuatu',
+ 'WF' => 'Wallis & Futuna',
+ 'WS' => 'Samoa',
+ 'XK' => 'Kosovo',
+ 'YE' => 'Yemen',
+ 'YT' => 'Mayotte',
+ 'ZA' => 'South Africa',
+ 'ZM' => 'Zambia',
+ 'ZW' => 'Zimbabwe',
+ ];
+
+ return $names[$code] ?? $unknown;
+}
diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php
index fd3fbe74ba..f177e6c16f 100644
--- a/bootstrap/helpers/socialite.php
+++ b/bootstrap/helpers/socialite.php
@@ -1,7 +1,13 @@
client_id,
$oauth_setting->client_secret,
$oauth_setting->redirect_uri,
@@ -23,7 +29,7 @@ function get_socialite_provider(string $provider)
}
if ($provider == 'authentik' || $provider == 'clerk') {
- $authentik_clerk_config = new \SocialiteProviders\Manager\Config(
+ $authentik_clerk_config = new Config(
$oauth_setting->client_id,
$oauth_setting->client_secret,
$oauth_setting->redirect_uri,
@@ -34,7 +40,7 @@ function get_socialite_provider(string $provider)
}
if ($provider == 'zitadel') {
- $zitadel_config = new \SocialiteProviders\Manager\Config(
+ $zitadel_config = new Config(
$oauth_setting->client_id,
$oauth_setting->client_secret,
$oauth_setting->redirect_uri,
@@ -44,8 +50,12 @@ function get_socialite_provider(string $provider)
return Socialite::driver('zitadel')->setConfig($zitadel_config);
}
+ if ($provider === 'oidc') {
+ return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting));
+ }
+
if ($provider == 'google') {
- $google_config = new \SocialiteProviders\Manager\Config(
+ $google_config = new Config(
$oauth_setting->client_id,
$oauth_setting->client_secret,
$oauth_setting->redirect_uri
@@ -63,11 +73,11 @@ function get_socialite_provider(string $provider)
];
$provider_class_map = [
- 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class,
- 'discord' => \SocialiteProviders\Discord\Provider::class,
- 'github' => \Laravel\Socialite\Two\GithubProvider::class,
- 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class,
- 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class,
+ 'bitbucket' => BitbucketProvider::class,
+ 'discord' => Provider::class,
+ 'github' => GithubProvider::class,
+ 'gitlab' => GitlabProvider::class,
+ 'infomaniak' => SocialiteProviders\Infomaniak\Provider::class,
];
$socialite = Socialite::buildProvider(
diff --git a/bun.lock b/bun.lock
index cbe08fb954..8083b14d6b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,6 +9,7 @@
"@tailwindcss/typography": "0.5.20",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
+ "cobe": "^2.0.1",
"playwright": "^1.58.2",
"tw-animate-css": "^1.4.0",
},
@@ -109,6 +110,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+ "cobe": ["cobe@2.0.1", "", {}, "sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag=="],
+
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
diff --git a/composer.json b/composer.json
index 416b5e8f65..4778cc91dd 100644
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,7 @@
"php": "^8.4",
"danharrin/livewire-rate-limiting": "^2.2.1",
"doctrine/dbal": "^4.4.4",
+ "firebase/php-jwt": "7.1.0",
"guzzlehttp/guzzle": "^7.15.3",
"laravel/fortify": "^1.37.3",
"laravel/framework": "^12.65.0",
@@ -63,7 +64,6 @@
"driftingly/rector-laravel": "^2.5.0",
"fakerphp/faker": "^1.24.1",
"laravel/boost": "^2.4.8",
- "laravel/dusk": "^8.6.0",
"laravel/pint": "^1.30.4",
"mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.9.5",
diff --git a/composer.lock b/composer.lock
index e5718b31b0..55be2166b6 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "971daeb1b3078a36428c0fb56bb895b7",
+ "content-hash": "13e5d201c34a64cdf53e80a21304c9d5",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -13698,80 +13698,6 @@
},
"time": "2026-05-19T20:09:50+00:00"
},
- {
- "name": "laravel/dusk",
- "version": "v8.6.0",
- "source": {
- "type": "git",
- "url": "https://github.com/laravel/dusk.git",
- "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143",
- "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "ext-zip": "*",
- "guzzlehttp/guzzle": "^7.5",
- "illuminate/console": "^10.0|^11.0|^12.0|^13.0",
- "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
- "php": "^8.1",
- "php-webdriver/webdriver": "^1.15.2",
- "symfony/console": "^6.2|^7.0|^8.0",
- "symfony/finder": "^6.2|^7.0|^8.0",
- "symfony/process": "^6.2|^7.0|^8.0",
- "vlucas/phpdotenv": "^5.2"
- },
- "require-dev": {
- "laravel/framework": "^10.0|^11.0|^12.0|^13.0",
- "mockery/mockery": "^1.6",
- "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^10.1|^11.0|^12.0.1",
- "psy/psysh": "^0.11.12|^0.12",
- "symfony/yaml": "^6.2|^7.0|^8.0"
- },
- "suggest": {
- "ext-pcntl": "Used to gracefully terminate Dusk when tests are running."
- },
- "type": "library",
- "extra": {
- "laravel": {
- "providers": [
- "Laravel\\Dusk\\DuskServiceProvider"
- ]
- }
- },
- "autoload": {
- "psr-4": {
- "Laravel\\Dusk\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Taylor Otwell",
- "email": "taylor@laravel.com"
- }
- ],
- "description": "Laravel Dusk provides simple end-to-end testing and browser automation.",
- "keywords": [
- "laravel",
- "testing",
- "webdriver"
- ],
- "support": {
- "issues": "https://github.com/laravel/dusk/issues",
- "source": "https://github.com/laravel/dusk/tree/v8.6.0"
- },
- "time": "2026-04-15T14:50:40+00:00"
- },
{
"name": "laravel/pint",
"version": "v1.30.4",
@@ -14817,72 +14743,6 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
- {
- "name": "php-webdriver/webdriver",
- "version": "1.16.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-webdriver/php-webdriver.git",
- "reference": "ac0662863aa120b4f645869f584013e4c4dba46a"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a",
- "reference": "ac0662863aa120b4f645869f584013e4c4dba46a",
- "shasum": ""
- },
- "require": {
- "ext-curl": "*",
- "ext-json": "*",
- "ext-zip": "*",
- "php": "^7.3 || ^8.0",
- "symfony/polyfill-mbstring": "^1.12",
- "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0"
- },
- "replace": {
- "facebook/webdriver": "*"
- },
- "require-dev": {
- "ergebnis/composer-normalize": "^2.20.0",
- "ondram/ci-detector": "^4.0",
- "php-coveralls/php-coveralls": "^2.4",
- "php-mock/php-mock-phpunit": "^2.0",
- "php-parallel-lint/php-parallel-lint": "^1.2",
- "phpunit/phpunit": "^9.3",
- "squizlabs/php_codesniffer": "^3.5",
- "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0"
- },
- "suggest": {
- "ext-simplexml": "For Firefox profile creation"
- },
- "type": "library",
- "autoload": {
- "files": [
- "lib/Exception/TimeoutException.php"
- ],
- "psr-4": {
- "Facebook\\WebDriver\\": "lib/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.",
- "homepage": "https://github.com/php-webdriver/php-webdriver",
- "keywords": [
- "Chromedriver",
- "geckodriver",
- "php",
- "selenium",
- "webdriver"
- ],
- "support": {
- "issues": "https://github.com/php-webdriver/php-webdriver/issues",
- "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0"
- },
- "time": "2025-12-28T23:57:40+00:00"
- },
{
"name": "phpstan/phpstan",
"version": "2.2.8",
diff --git a/config/app.php b/config/app.php
index 13a5b7d4b8..59aa6f4c28 100644
--- a/config/app.php
+++ b/config/app.php
@@ -193,8 +193,8 @@ return [
*/
'maintenance' => [
- 'driver' => 'cache',
- 'store' => 'redis',
+ 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'),
+ 'store' => env('APP_MAINTENANCE_STORE', 'redis'),
],
/*
diff --git a/config/logging.php b/config/logging.php
index 05cf8e13d3..89c9d38dde 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -133,13 +133,6 @@ return [
'days' => 14,
],
- 'audit' => [
- 'driver' => 'daily',
- 'path' => storage_path('logs/audit.log'),
- 'level' => env('LOG_AUDIT_LEVEL', 'info'),
- 'days' => env('LOG_AUDIT_DAYS', 90),
- 'replace_placeholders' => true,
- ],
],
];
diff --git a/config/services.php b/config/services.php
index c5956cf6c9..3a2a0631ef 100644
--- a/config/services.php
+++ b/config/services.php
@@ -60,6 +60,14 @@ return [
'tenant' => env('GOOGLE_TENANT'),
],
+ 'oidc' => [
+ 'client_id' => env('OIDC_CLIENT_ID'),
+ 'client_secret' => env('OIDC_CLIENT_SECRET'),
+ 'redirect' => env('OIDC_REDIRECT_URI'),
+ 'base_url' => env('OIDC_BASE_URL'),
+ 'custom_label' => env('OIDC_LOGIN_LABEL'),
+ ],
+
'zitadel' => [
'client_id' => env('ZITADEL_CLIENT_ID'),
'client_secret' => env('ZITADEL_CLIENT_SECRET'),
diff --git a/database/factories/AuditEventFactory.php b/database/factories/AuditEventFactory.php
new file mode 100644
index 0000000000..01ddebbd2b
--- /dev/null
+++ b/database/factories/AuditEventFactory.php
@@ -0,0 +1,29 @@
+
+ */
+class AuditEventFactory extends Factory
+{
+ protected $model = AuditEvent::class;
+
+ public function definition(): array
+ {
+ return [
+ 'team_id' => Team::factory(),
+ 'event' => 'ui.application.updated',
+ 'source' => 'ui',
+ 'action' => 'updated',
+ 'actor_type' => 'user',
+ 'description' => 'Application updated',
+ 'metadata' => [],
+ 'created_at' => now(),
+ ];
+ }
+}
diff --git a/database/factories/DnsProviderZoneFactory.php b/database/factories/DnsProviderZoneFactory.php
new file mode 100644
index 0000000000..1b34aa5ffc
--- /dev/null
+++ b/database/factories/DnsProviderZoneFactory.php
@@ -0,0 +1,20 @@
+ IntegrationToken::factory(), 'provider_zone_id' => fake()->uuid(),
+ 'name' => fake()->unique()->domainName(), 'account_id' => fake()->uuid(), 'account_name' => fake()->company(),
+ ];
+ }
+}
diff --git a/database/factories/IntegrationTokenFactory.php b/database/factories/IntegrationTokenFactory.php
new file mode 100644
index 0000000000..b78f932947
--- /dev/null
+++ b/database/factories/IntegrationTokenFactory.php
@@ -0,0 +1,20 @@
+ Team::factory(), 'provider' => 'cloudflare', 'name' => fake()->words(2, true),
+ 'token' => fake()->sha256(), 'capabilities' => ['dns'],
+ ];
+ }
+}
diff --git a/database/factories/ManagedDnsRecordFactory.php b/database/factories/ManagedDnsRecordFactory.php
new file mode 100644
index 0000000000..e4c163036d
--- /dev/null
+++ b/database/factories/ManagedDnsRecordFactory.php
@@ -0,0 +1,22 @@
+ DnsProviderZone::factory(),
+ 'integration_token_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integration_token_id,
+ 'team_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integrationToken->team_id,
+ 'provider_record_id' => fake()->uuid(), 'type' => 'A', 'name' => fake()->domainName(), 'content' => fake()->ipv4(),
+ ];
+ }
+}
diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php
index 19c4445b26..13fe6b6784 100644
--- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php
+++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php
@@ -8,6 +8,12 @@ return new class extends Migration
/**
* The configuration snapshot/diff now store an encrypted blob (not valid
* JSON), so the columns must hold arbitrary text instead of json.
+ *
+ * Coolify's own backend runs exclusively on PostgreSQL in production and
+ * SQLite in testing (see config/database.php β the only configured
+ * connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed
+ * resources, never Coolify's application database, so no driver path is
+ * needed for them here.
*/
public function up(): void
{
diff --git a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php
new file mode 100644
index 0000000000..3160ef9ddb
--- /dev/null
+++ b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php
@@ -0,0 +1,40 @@
+string('custom_label')->nullable();
+ $table->string('scopes')->nullable();
+ $table->boolean('allow_registration')->default(true);
+ $table->boolean('require_email_verified')->default(true);
+ $table->boolean('use_pkce')->default(true);
+ $table->unsignedSmallInteger('clock_skew_seconds')->default(60);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('oauth_settings', function (Blueprint $table) {
+ $table->dropColumn([
+ 'custom_label',
+ 'scopes',
+ 'allow_registration',
+ 'require_email_verified',
+ 'use_pkce',
+ 'clock_skew_seconds',
+ ]);
+ });
+ }
+};
diff --git a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php
new file mode 100644
index 0000000000..9f838e5779
--- /dev/null
+++ b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('provider');
+ $table->string('issuer');
+ $table->string('provider_user_id');
+ $table->string('email')->nullable()->index();
+ $table->json('raw_claims')->nullable();
+ $table->timestamp('last_login_at')->nullable();
+ $table->timestamps();
+
+ $table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('oauth_identities');
+ }
+};
diff --git a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php
new file mode 100644
index 0000000000..06c0f1dd52
--- /dev/null
+++ b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php
@@ -0,0 +1,28 @@
+boolean('disable_registration_when_oauth_enabled')->default(false);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('instance_settings', function (Blueprint $table) {
+ $table->dropColumn('disable_registration_when_oauth_enabled');
+ });
+ }
+};
diff --git a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php
new file mode 100644
index 0000000000..b0f5aad18a
--- /dev/null
+++ b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php
@@ -0,0 +1,28 @@
+boolean('auto_join_root_team')->default(false);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('oauth_settings', function (Blueprint $table) {
+ $table->dropColumn('auto_join_root_team');
+ });
+ }
+};
diff --git a/database/migrations/2026_08_10_191228_add_traffic_analytics_to_server_settings.php b/database/migrations/2026_08_10_191228_add_traffic_analytics_to_server_settings.php
new file mode 100644
index 0000000000..535469ab8b
--- /dev/null
+++ b/database/migrations/2026_08_10_191228_add_traffic_analytics_to_server_settings.php
@@ -0,0 +1,44 @@
+boolean('is_traffic_analytics_enabled')->default(false);
+ $table->text('geoip_maxmind_license_key')->nullable();
+ $table->integer('traffic_topn')->default(50);
+ $table->integer('traffic_sample_threshold')->default(0);
+ $table->integer('traffic_retention_1h_days')->default(30);
+ $table->integer('traffic_retention_1d_days')->default(395);
+ $table->boolean('is_geoip_enabled')->default(true);
+ $table->integer('geoip_refresh_days')->default(30);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('server_settings', function (Blueprint $table) {
+ $table->dropColumn([
+ 'is_traffic_analytics_enabled',
+ 'geoip_maxmind_license_key',
+ 'traffic_topn',
+ 'traffic_sample_threshold',
+ 'traffic_retention_1h_days',
+ 'traffic_retention_1d_days',
+ 'is_geoip_enabled',
+ 'geoip_refresh_days',
+ ]);
+ });
+ }
+};
diff --git a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php
new file mode 100644
index 0000000000..a17d3972d5
--- /dev/null
+++ b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->string('uuid')->unique();
+ $table->foreignId('team_id')->constrained()->cascadeOnDelete();
+ $table->string('provider');
+ $table->string('name');
+ $table->text('token');
+ $table->json('capabilities');
+ $table->timestamps();
+
+ $table->index(['team_id', 'provider']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('integration_tokens');
+ }
+};
diff --git a/database/migrations/2026_08_20_000000_create_audit_events_table.php b/database/migrations/2026_08_20_000000_create_audit_events_table.php
new file mode 100644
index 0000000000..0ace21f229
--- /dev/null
+++ b/database/migrations/2026_08_20_000000_create_audit_events_table.php
@@ -0,0 +1,45 @@
+id();
+ $table->unsignedBigInteger('team_id')->nullable();
+ $table->string('event');
+ $table->string('source', 32);
+ $table->string('action', 64);
+ $table->string('actor_type', 32);
+ $table->unsignedBigInteger('actor_id')->nullable();
+ $table->string('actor_name')->nullable();
+ $table->string('actor_email')->nullable();
+ $table->unsignedBigInteger('actor_token_id')->nullable();
+ $table->string('actor_token_name')->nullable();
+ $table->string('resource_type')->nullable();
+ $table->string('resource_uuid')->nullable();
+ $table->string('resource_name')->nullable();
+ $table->text('description');
+ $table->json('metadata')->nullable();
+ $table->string('ip_address', 45)->nullable();
+ $table->string('user_agent', 200)->nullable();
+ $table->timestamp('created_at')->useCurrent();
+
+ $table->index('created_at');
+ $table->index(['team_id', 'created_at', 'id']);
+ $table->index(['team_id', 'action', 'created_at', 'id']);
+ $table->index(['team_id', 'source', 'created_at', 'id']);
+ $table->index(['team_id', 'resource_type', 'resource_uuid', 'created_at']);
+ $table->index(['team_id', 'actor_id', 'created_at']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('audit_events');
+ }
+};
diff --git a/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php
new file mode 100644
index 0000000000..744697628f
--- /dev/null
+++ b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php
@@ -0,0 +1,36 @@
+json('metadata')->nullable()->after('capabilities');
+ });
+
+ Schema::create('secret_manager_links', function (Blueprint $table) {
+ $table->id();
+ $table->string('uuid')->unique();
+ $table->string('resourceable_type');
+ $table->unsignedBigInteger('resourceable_id');
+ $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete();
+ $table->json('settings')->nullable();
+ $table->timestamps();
+
+ $table->unique(['resourceable_type', 'resourceable_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('secret_manager_links');
+
+ Schema::table('integration_tokens', function (Blueprint $table) {
+ $table->dropColumn('metadata');
+ });
+ }
+};
diff --git a/database/migrations/2026_08_24_000000_create_dns_provider_zones_table.php b/database/migrations/2026_08_24_000000_create_dns_provider_zones_table.php
new file mode 100644
index 0000000000..690e535311
--- /dev/null
+++ b/database/migrations/2026_08_24_000000_create_dns_provider_zones_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->string('uuid')->unique();
+ $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete();
+ $table->string('provider_zone_id');
+ $table->string('name');
+ $table->string('account_id')->nullable();
+ $table->string('account_name')->nullable();
+ $table->timestamps();
+ $table->unique(['integration_token_id', 'provider_zone_id']);
+ $table->index('name');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('dns_provider_zones');
+ }
+};
diff --git a/database/migrations/2026_08_24_000001_create_managed_dns_records_table.php b/database/migrations/2026_08_24_000001_create_managed_dns_records_table.php
new file mode 100644
index 0000000000..6fbcba6089
--- /dev/null
+++ b/database/migrations/2026_08_24_000001_create_managed_dns_records_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->string('uuid')->unique();
+ $table->foreignId('team_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('dns_provider_zone_id')->constrained()->cascadeOnDelete();
+ $table->nullableMorphs('resource');
+ $table->string('provider_record_id');
+ $table->string('type', 16);
+ $table->string('name');
+ $table->string('content');
+ $table->timestamps();
+ $table->unique(['dns_provider_zone_id', 'provider_record_id']);
+ $table->index(['team_id', 'name']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('managed_dns_records');
+ }
+};
diff --git a/database/migrations/2026_09_08_214510_align_consistent_container_name_with_custom_internal_name.php b/database/migrations/2026_09_08_214510_align_consistent_container_name_with_custom_internal_name.php
new file mode 100644
index 0000000000..30acb09200
--- /dev/null
+++ b/database/migrations/2026_09_08_214510_align_consistent_container_name_with_custom_internal_name.php
@@ -0,0 +1,21 @@
+whereNotNull('custom_internal_name')
+ ->where('custom_internal_name', '!=', '')
+ ->where('is_consistent_container_name_enabled', false)
+ ->update(['is_consistent_container_name_enabled' => true]);
+ }
+};
diff --git a/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php b/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php
new file mode 100644
index 0000000000..d49c1d57aa
--- /dev/null
+++ b/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php
@@ -0,0 +1,18 @@
+string('custom_container_name_prefix')->nullable();
+ });
+ }
+};
diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php
index 2e3e63defd..f916c4a9cd 100644
--- a/database/seeders/OauthSettingSeeder.php
+++ b/database/seeders/OauthSettingSeeder.php
@@ -23,6 +23,7 @@ class OauthSettingSeeder extends Seeder
'github',
'gitlab',
'google',
+ 'oidc',
'authentik',
'infomaniak',
'zitadel',
diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js
index 0d13dc18f1..c2762f1d85 100644
--- a/docker/coolify-realtime/terminal-utils.js
+++ b/docker/coolify-realtime/terminal-utils.js
@@ -28,7 +28,7 @@ function normalizeShellArgument(argument) {
}
export function extractSshArgs(commandString) {
- const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
+ const sshCommandMatch = commandString.match(/ssh (.+?) '[^']+' << /);
if (!sshCommandMatch) return [];
const argsString = sshCommandMatch[1];
diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js
index 21625eece4..e9acda3270 100644
--- a/docker/coolify-realtime/terminal-utils.test.js
+++ b/docker/coolify-realtime/terminal-utils.test.js
@@ -63,6 +63,14 @@ test('extractSshArgs preserves proxy command as a single normalized ssh option v
assert.equal(sshArgs[4], 'root@example.com');
});
+test('extractSshArgs supports the generated bash or sh fallback command', () => {
+ const sshArgs = extractSshArgs(
+ "timeout 3600 ssh -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\\\$abc\necho hi\nabc"
+ );
+
+ assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
+});
+
test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true);
assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true);
diff --git a/lang/de.json b/lang/de.json
index 7c43300e67..cbc2237a75 100644
--- a/lang/de.json
+++ b/lang/de.json
@@ -7,6 +7,7 @@
"auth.login.github": "Mit GitHub anmelden",
"auth.login.gitlab": "Mit GitLab anmelden",
"auth.login.google": "Mit Google anmelden",
+ "auth.login.oidc": "Mit SSO anmelden",
"auth.login.infomaniak": "Mit Infomaniak anmelden",
"auth.login.zitadel": "Mit Zitadel anmelden",
"auth.already_registered": "Bereits registriert?",
diff --git a/lang/en.json b/lang/en.json
index 12c21b6665..b97a10d629 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -8,6 +8,7 @@
"auth.login.github": "Login with GitHub",
"auth.login.gitlab": "Login with Gitlab",
"auth.login.google": "Login with Google",
+ "auth.login.oidc": "Login with SSO",
"auth.login.infomaniak": "Login with Infomaniak",
"auth.login.zitadel": "Login with Zitadel",
"auth.already_registered": "Already registered?",
diff --git a/lang/pl.json b/lang/pl.json
index bcd8e23937..b05437ac4e 100644
--- a/lang/pl.json
+++ b/lang/pl.json
@@ -8,6 +8,7 @@
"auth.login.github": "Zaloguj siΔ przez GitHub",
"auth.login.gitlab": "Zaloguj siΔ przez Gitlab",
"auth.login.google": "Zaloguj siΔ przez Google",
+ "auth.login.oidc": "Zaloguj siΔ przez SSO",
"auth.login.infomaniak": "Zaloguj siΔ przez Infomaniak",
"auth.login.zitadel": "Zaloguj siΔ przez Zitadel",
"auth.already_registered": "JuΕΌ zarejestrowany?",
diff --git a/openapi.json b/openapi.json
index 718191d3cf..6695b46132 100644
--- a/openapi.json
+++ b/openapi.json
@@ -11,6 +11,66 @@
}
],
"paths": {
+ "\/applications\/{uuid}\/secret-manager": {
+ "patch": {
+ "tags": [
+ "Secret Managers"
+ ],
+ "summary": "Configure Application Secret Manager",
+ "description": "Configure the secret manager source used by an application.",
+ "operationId": "configure-application-secret-manager",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "integration_token_uuid"
+ ],
+ "properties": {
+ "integration_token_uuid": {
+ "type": "string"
+ },
+ "settings": {
+ "type": "object"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Secret manager configured."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/applications": {
"get": {
"tags": [
@@ -3508,6 +3568,175 @@
]
}
},
+ "\/applications\/{uuid}\/previews\/{pull_request_id}": {
+ "delete": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Delete Preview Deployment",
+ "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.",
+ "operationId": "delete-preview-deployment-by-pull-request-id",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "pull_request_id",
+ "in": "path",
+ "description": "Pull request ID of the preview to delete.",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Preview deletion queued.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "patch": {
+ "tags": [
+ "Applications"
+ ],
+ "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.",
+ "operationId": "update-preview-domains-by-pull-request-id",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "pull_request_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "domains": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "example": "https:\/\/pr.example.com:3000"
+ },
+ "docker_compose_domains": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "domain": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "redirect": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "www",
+ "non-www",
+ "both"
+ ]
+ }
+ },
+ "type": "object"
+ }
+ },
+ "force_domain_override": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Preview domains updated."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "403": {
+ "$ref": "#\/components\/responses\/403"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "409": {
+ "description": "Domain conflict."
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/applications\/{uuid}\/envs": {
"get": {
"tags": [
@@ -4568,70 +4797,6 @@
]
}
},
- "\/applications\/{uuid}\/previews\/{pull_request_id}": {
- "delete": {
- "tags": [
- "Applications"
- ],
- "summary": "Delete Preview Deployment",
- "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.",
- "operationId": "delete-preview-deployment-by-pull-request-id",
- "parameters": [
- {
- "name": "uuid",
- "in": "path",
- "description": "UUID of the application.",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "pull_request_id",
- "in": "path",
- "description": "Pull request ID of the preview to delete.",
- "required": true,
- "schema": {
- "type": "integer"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Preview deletion queued.",
- "content": {
- "application\/json": {
- "schema": {
- "properties": {
- "message": {
- "type": "string"
- }
- },
- "type": "object"
- }
- }
- }
- },
- "401": {
- "$ref": "#\/components\/responses\/401"
- },
- "400": {
- "$ref": "#\/components\/responses\/400"
- },
- "404": {
- "$ref": "#\/components\/responses\/404"
- },
- "422": {
- "$ref": "#\/components\/responses\/422"
- }
- },
- "security": [
- {
- "bearerAuth": []
- }
- ]
- }
- },
"\/applications\/{uuid}\/tags": {
"get": {
"tags": [
@@ -5778,6 +5943,134 @@
]
}
},
+ "\/databases\/{uuid}\/imports\/uploads": {
+ "post": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Upload database import",
+ "operationId": "upload-database-import",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Upload completed"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/databases\/{uuid}\/imports": {
+ "post": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Import database backup",
+ "operationId": "create-database-import",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/DatabaseImportRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "202": {
+ "description": "Import queued"
+ },
+ "409": {
+ "description": "Import already active"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/databases\/{uuid}\/imports\/{activity_id}": {
+ "get": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Get database import status",
+ "operationId": "get-database-import",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "activity_id",
+ "in": "path",
+ "description": "Import activity ID.",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Import status",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/DatabaseImportStatus"
+ }
+ }
+ }
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/databases": {
"get": {
"tags": [
@@ -5946,6 +6239,13 @@
"type": "integer",
"description": "Backup job timeout in seconds (min: 60, max: 36000)",
"default": 3600
+ },
+ "missing_backup_notification_days": {
+ "type": "integer",
+ "description": "Alert after this many days without an execution; 0 disables alerts",
+ "minimum": 0,
+ "maximum": 365,
+ "default": 0
}
},
"type": "object"
@@ -6545,6 +6845,12 @@
"type": "integer",
"description": "Backup job timeout in seconds (min: 60, max: 36000)",
"default": 3600
+ },
+ "missing_backup_notification_days": {
+ "type": "integer",
+ "description": "Alert after this many days without an execution; 0 disables alerts",
+ "minimum": 0,
+ "maximum": 365
}
},
"type": "object"
@@ -11689,13 +11995,129 @@
]
}
},
+ "\/settings\/email": {
+ "get": {
+ "tags": [
+ "Settings"
+ ],
+ "summary": "Get instance email settings",
+ "description": "Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.",
+ "operationId": "get-instance-email-settings",
+ "responses": {
+ "200": {
+ "description": "Instance email settings."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "403": {
+ "description": "Forbidden."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "patch": {
+ "tags": [
+ "Settings"
+ ],
+ "summary": "Update instance email settings",
+ "description": "Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.",
+ "operationId": "update-instance-email-settings",
+ "responses": {
+ "200": {
+ "description": "Updated instance email settings."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "403": {
+ "description": "Forbidden."
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/security\/integration-tokens": {
+ "post": {
+ "tags": [
+ "Secret Managers"
+ ],
+ "summary": "Create Secret Manager Token",
+ "description": "Create and validate a Doppler, Infisical, or Vault integration token.",
+ "operationId": "create-secret-manager-integration-token",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "provider",
+ "name",
+ "token"
+ ],
+ "properties": {
+ "provider": {
+ "type": "string",
+ "enum": [
+ "doppler",
+ "infisical",
+ "vault"
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "token": {
+ "type": "string"
+ },
+ "metadata": {
+ "type": "object"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Integration token created."
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/notifications\/email": {
"get": {
"tags": [
"Notifications"
],
"summary": "Get email notification settings",
- "description": "Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.",
+ "description": "Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.",
"operationId": "get-current-team-email-notifications",
"responses": {
"200": {
@@ -11719,7 +12141,7 @@
"Notifications"
],
"summary": "Update email notification settings",
- "description": "Update the current team email notification settings.",
+ "description": "Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.",
"operationId": "update-current-team-email-notifications",
"responses": {
"200": {
@@ -15563,6 +15985,28 @@
"string",
"null"
]
+ },
+ "traffic_topn": {
+ "type": "integer"
+ },
+ "traffic_sample_threshold": {
+ "type": "integer"
+ },
+ "traffic_retention_1h_days": {
+ "type": "integer"
+ },
+ "traffic_retention_1d_days": {
+ "type": "integer"
+ },
+ "is_geoip_enabled": {
+ "type": "boolean"
+ },
+ "geoip_refresh_days": {
+ "type": "integer"
+ },
+ "geoip_maxmind_license_key": {
+ "description": "Only present with read:sensitive.",
+ "type": "string"
}
},
"type": "object"
@@ -15639,6 +16083,35 @@
"string",
"null"
]
+ },
+ "traffic_topn": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "traffic_sample_threshold": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "traffic_retention_1h_days": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "traffic_retention_1d_days": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "is_geoip_enabled": {
+ "type": "boolean"
+ },
+ "geoip_refresh_days": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "geoip_maxmind_license_key": {
+ "type": [
+ "string",
+ "null"
+ ]
}
},
"type": "object"
@@ -16824,6 +17297,12 @@
"null"
],
"minimum": 0
+ },
+ "is_force_https_enabled": {
+ "type": [
+ "boolean",
+ "null"
+ ]
}
},
"type": "object"
@@ -17211,6 +17690,161 @@
]
}
},
+ "\/services\/{uuid}\/databases\/{database_uuid}\/imports\/uploads": {
+ "post": {
+ "tags": [
+ "Service databases"
+ ],
+ "summary": "Upload service database import",
+ "operationId": "upload-service-database-import",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Service UUID.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "database_uuid",
+ "in": "path",
+ "description": "Service database UUID.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Upload completed"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/services\/{uuid}\/databases\/{database_uuid}\/imports": {
+ "post": {
+ "tags": [
+ "Service databases"
+ ],
+ "summary": "Import service database backup",
+ "operationId": "create-service-database-import",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Service UUID.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "database_uuid",
+ "in": "path",
+ "description": "Service database UUID.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/DatabaseImportRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "202": {
+ "description": "Import queued"
+ },
+ "409": {
+ "description": "Import already active"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/services\/{uuid}\/databases\/{database_uuid}\/imports\/{activity_id}": {
+ "get": {
+ "tags": [
+ "Service databases"
+ ],
+ "summary": "Get service database import status",
+ "operationId": "get-service-database-import",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Service UUID.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "database_uuid",
+ "in": "path",
+ "description": "Service database UUID.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "activity_id",
+ "in": "path",
+ "description": "Import activity ID.",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Import status",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/DatabaseImportStatus"
+ }
+ }
+ }
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/services\/{uuid}\/databases": {
"get": {
"tags": [
@@ -21410,6 +22044,145 @@
},
"components": {
"schemas": {
+ "DatabaseImportRequest": {
+ "type": "object",
+ "oneOf": [
+ {
+ "required": [
+ "source",
+ "upload_id"
+ ],
+ "properties": {
+ "source": {
+ "type": "string",
+ "enum": [
+ "upload"
+ ]
+ },
+ "upload_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "dump_all": {
+ "type": "boolean",
+ "default": false
+ },
+ "replace_existing": {
+ "description": "Drop matching PostgreSQL objects before restoring a single-database archive.",
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "type": "object",
+ "additionalProperties": false
+ },
+ {
+ "required": [
+ "source",
+ "s3_storage_uuid",
+ "path"
+ ],
+ "properties": {
+ "source": {
+ "type": "string",
+ "enum": [
+ "s3"
+ ]
+ },
+ "s3_storage_uuid": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "dump_all": {
+ "type": "boolean",
+ "default": false
+ },
+ "replace_existing": {
+ "description": "Drop matching PostgreSQL objects before restoring a single-database archive.",
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "type": "object",
+ "additionalProperties": false
+ },
+ {
+ "required": [
+ "source",
+ "path"
+ ],
+ "properties": {
+ "source": {
+ "type": "string",
+ "enum": [
+ "server"
+ ]
+ },
+ "path": {
+ "type": "string",
+ "example": "\/var\/backups\/database.sql.gz"
+ },
+ "dump_all": {
+ "type": "boolean",
+ "default": false
+ },
+ "replace_existing": {
+ "description": "Drop matching PostgreSQL objects before restoring a single-database archive.",
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "type": "object",
+ "additionalProperties": false
+ }
+ ]
+ },
+ "DatabaseImportStatus": {
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "queued",
+ "in_progress",
+ "finished",
+ "error",
+ "killed",
+ "cancelled",
+ "closed"
+ ]
+ },
+ "exit_code": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "output": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "finished_at": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ }
+ },
+ "type": "object"
+ },
"VolumeBackupScheduleRequest": {
"required": [
"frequency"
@@ -21482,7 +22255,7 @@
},
"timeout": {
"type": "integer",
- "default": 3600,
+ "default": 36000,
"maximum": 36000,
"minimum": 60
}
@@ -22528,6 +23301,9 @@
"deployment_queue_limit": {
"type": "integer"
},
+ "backup_compression_cpu_percentage": {
+ "type": "integer"
+ },
"dynamic_timeout": {
"type": "integer"
},
@@ -22561,6 +23337,27 @@
"is_metrics_enabled": {
"type": "boolean"
},
+ "is_traffic_analytics_enabled": {
+ "type": "boolean"
+ },
+ "traffic_topn": {
+ "type": "integer"
+ },
+ "traffic_sample_threshold": {
+ "type": "integer"
+ },
+ "traffic_retention_1h_days": {
+ "type": "integer"
+ },
+ "traffic_retention_1d_days": {
+ "type": "integer"
+ },
+ "is_geoip_enabled": {
+ "type": "boolean"
+ },
+ "geoip_refresh_days": {
+ "type": "integer"
+ },
"is_reachable": {
"type": "boolean"
},
@@ -22638,6 +23435,26 @@
"connection_timeout": {
"type": "integer",
"description": "SSH connection timeout in seconds."
+ },
+ "docker_version": {
+ "type": "string",
+ "nullable": true,
+ "description": "Detected Docker Engine version on the server."
+ },
+ "docker_version_checked_at": {
+ "type": "string",
+ "nullable": true,
+ "description": "When Docker Engine version was last detected."
+ },
+ "compose_version": {
+ "type": "string",
+ "nullable": true,
+ "description": "Detected Docker Compose plugin version on the server."
+ },
+ "compose_version_checked_at": {
+ "type": "string",
+ "nullable": true,
+ "description": "When Docker Compose version was last detected."
}
},
"type": "object"
@@ -22977,6 +23794,10 @@
}
},
"tags": [
+ {
+ "name": "Secret Managers",
+ "description": "Secret Managers"
+ },
{
"name": "Applications",
"description": "Applications"
@@ -23017,6 +23838,10 @@
"name": "Hetzner",
"description": "Hetzner"
},
+ {
+ "name": "Settings",
+ "description": "Settings"
+ },
{
"name": "Notifications",
"description": "Notifications"
diff --git a/openapi.yaml b/openapi.yaml
index a53ab1439a..53d98109ec 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -7,6 +7,45 @@ servers:
url: 'https://app.coolify.io/api/v1'
description: 'Coolify Cloud API. Change the host to your own instance if you are self-hosting.'
paths:
+ '/applications/{uuid}/secret-manager':
+ patch:
+ tags:
+ - 'Secret Managers'
+ summary: 'Configure Application Secret Manager'
+ description: 'Configure the secret manager source used by an application.'
+ operationId: configure-application-secret-manager
+ parameters:
+ -
+ name: uuid
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - integration_token_uuid
+ properties:
+ integration_token_uuid:
+ type: string
+ settings:
+ type: object
+ type: object
+ responses:
+ '200':
+ description: 'Secret manager configured.'
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
/applications:
get:
tags:
@@ -2307,6 +2346,99 @@ paths:
security:
-
bearerAuth: []
+ '/applications/{uuid}/previews/{pull_request_id}':
+ delete:
+ tags:
+ - Applications
+ summary: 'Delete Preview Deployment'
+ description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.'
+ operationId: delete-preview-deployment-by-pull-request-id
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ -
+ name: pull_request_id
+ in: path
+ description: 'Pull request ID of the preview to delete.'
+ required: true
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: 'Preview deletion queued.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ patch:
+ tags:
+ - Applications
+ 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.'
+ operationId: update-preview-domains-by-pull-request-id
+ parameters:
+ -
+ name: uuid
+ in: path
+ required: true
+ schema:
+ type: string
+ -
+ name: pull_request_id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ properties:
+ domains:
+ type: [string, 'null']
+ example: 'https://pr.example.com:3000'
+ docker_compose_domains:
+ type: [array, 'null']
+ items: { properties: { name: { type: string }, domain: { type: [string, 'null'] }, redirect: { type: [string, 'null'], enum: [www, non-www, both] } }, type: object }
+ force_domain_override:
+ type: boolean
+ default: false
+ type: object
+ responses:
+ '200':
+ description: 'Preview domains updated.'
+ '401':
+ $ref: '#/components/responses/401'
+ '403':
+ $ref: '#/components/responses/403'
+ '404':
+ $ref: '#/components/responses/404'
+ '409':
+ description: 'Domain conflict.'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
'/applications/{uuid}/envs':
get:
tags:
@@ -2974,48 +3106,6 @@ paths:
security:
-
bearerAuth: []
- '/applications/{uuid}/previews/{pull_request_id}':
- delete:
- tags:
- - Applications
- summary: 'Delete Preview Deployment'
- description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.'
- operationId: delete-preview-deployment-by-pull-request-id
- parameters:
- -
- name: uuid
- in: path
- description: 'UUID of the application.'
- required: true
- schema:
- type: string
- -
- name: pull_request_id
- in: path
- description: 'Pull request ID of the preview to delete.'
- required: true
- schema:
- type: integer
- responses:
- '200':
- description: 'Preview deletion queued.'
- content:
- application/json:
- schema:
- properties:
- message: { type: string }
- type: object
- '401':
- $ref: '#/components/responses/401'
- '400':
- $ref: '#/components/responses/400'
- '404':
- $ref: '#/components/responses/404'
- '422':
- $ref: '#/components/responses/422'
- security:
- -
- bearerAuth: []
'/applications/{uuid}/tags':
get:
tags:
@@ -3720,6 +3810,91 @@ paths:
security:
-
bearerAuth: []
+ '/databases/{uuid}/imports/uploads':
+ post:
+ tags:
+ - Databases
+ summary: 'Upload database import'
+ operationId: upload-database-import
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '201':
+ description: 'Upload completed'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/databases/{uuid}/imports':
+ post:
+ tags:
+ - Databases
+ summary: 'Import database backup'
+ operationId: create-database-import
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DatabaseImportRequest'
+ responses:
+ '202':
+ description: 'Import queued'
+ '409':
+ description: 'Import already active'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/databases/{uuid}/imports/{activity_id}':
+ get:
+ tags:
+ - Databases
+ summary: 'Get database import status'
+ operationId: get-database-import
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ -
+ name: activity_id
+ in: path
+ description: 'Import activity ID.'
+ required: true
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: 'Import status'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DatabaseImportStatus'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
/databases:
get:
tags:
@@ -3843,6 +4018,12 @@ paths:
type: integer
description: 'Backup job timeout in seconds (min: 60, max: 36000)'
default: 3600
+ missing_backup_notification_days:
+ type: integer
+ description: 'Alert after this many days without an execution; 0 disables alerts'
+ minimum: 0
+ maximum: 365
+ default: 0
type: object
responses:
'201':
@@ -4262,6 +4443,11 @@ paths:
type: integer
description: 'Backup job timeout in seconds (min: 60, max: 36000)'
default: 3600
+ missing_backup_notification_days:
+ type: integer
+ description: 'Alert after this many days without an execution; 0 disables alerts'
+ minimum: 0
+ maximum: 365
type: object
responses:
'200':
@@ -7490,12 +7676,86 @@ paths:
security:
-
bearerAuth: []
+ /settings/email:
+ get:
+ tags:
+ - Settings
+ summary: 'Get instance email settings'
+ description: 'Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.'
+ operationId: get-instance-email-settings
+ responses:
+ '200':
+ description: 'Instance email settings.'
+ '401':
+ $ref: '#/components/responses/401'
+ '403':
+ description: Forbidden.
+ security:
+ -
+ bearerAuth: []
+ patch:
+ tags:
+ - Settings
+ summary: 'Update instance email settings'
+ description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.'
+ operationId: update-instance-email-settings
+ responses:
+ '200':
+ description: 'Updated instance email settings.'
+ '401':
+ $ref: '#/components/responses/401'
+ '403':
+ description: Forbidden.
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ /security/integration-tokens:
+ post:
+ tags:
+ - 'Secret Managers'
+ summary: 'Create Secret Manager Token'
+ description: 'Create and validate a Doppler, Infisical, or Vault integration token.'
+ operationId: create-secret-manager-integration-token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - provider
+ - name
+ - token
+ properties:
+ provider:
+ type: string
+ enum: [doppler, infisical, vault]
+ name:
+ type: string
+ token:
+ type: string
+ metadata:
+ type: object
+ type: object
+ responses:
+ '201':
+ description: 'Integration token created.'
+ '400':
+ $ref: '#/components/responses/400'
+ '401':
+ $ref: '#/components/responses/401'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
/notifications/email:
get:
tags:
- Notifications
summary: 'Get email notification settings'
- description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.'
+ description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.'
operationId: get-current-team-email-notifications
responses:
'200':
@@ -7511,7 +7771,7 @@ paths:
tags:
- Notifications
summary: 'Update email notification settings'
- description: 'Update the current team email notification settings.'
+ description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.'
operationId: update-current-team-email-notifications
responses:
'200':
@@ -9872,6 +10132,13 @@ paths:
sentinel_push_interval_seconds: { type: integer }
sentinel_custom_url: { description: 'Only present with read:sensitive.', type: string }
sentinel_updated_at: { type: [string, 'null'] }
+ traffic_topn: { type: integer }
+ traffic_sample_threshold: { type: integer }
+ traffic_retention_1h_days: { type: integer }
+ traffic_retention_1d_days: { type: integer }
+ is_geoip_enabled: { type: boolean }
+ geoip_refresh_days: { type: integer }
+ geoip_maxmind_license_key: { description: 'Only present with read:sensitive.', type: string }
type: object
'401':
$ref: '#/components/responses/401'
@@ -9921,6 +10188,25 @@ paths:
minimum: 10
sentinel_custom_url:
type: [string, 'null']
+ traffic_topn:
+ type: integer
+ minimum: 1
+ traffic_sample_threshold:
+ type: integer
+ minimum: 0
+ traffic_retention_1h_days:
+ type: integer
+ minimum: 1
+ traffic_retention_1d_days:
+ type: integer
+ minimum: 1
+ is_geoip_enabled:
+ type: boolean
+ geoip_refresh_days:
+ type: integer
+ minimum: 1
+ geoip_maxmind_license_key:
+ type: [string, 'null']
type: object
responses:
'200':
@@ -10662,6 +10948,8 @@ paths:
description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'
type: [integer, 'null']
minimum: 0
+ is_force_https_enabled:
+ type: [boolean, 'null']
type: object
responses:
'200':
@@ -10913,6 +11201,112 @@ paths:
security:
-
bearerAuth: []
+ '/services/{uuid}/databases/{database_uuid}/imports/uploads':
+ post:
+ tags:
+ - 'Service databases'
+ summary: 'Upload service database import'
+ operationId: upload-service-database-import
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Service UUID.'
+ required: true
+ schema:
+ type: string
+ -
+ name: database_uuid
+ in: path
+ description: 'Service database UUID.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '201':
+ description: 'Upload completed'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/services/{uuid}/databases/{database_uuid}/imports':
+ post:
+ tags:
+ - 'Service databases'
+ summary: 'Import service database backup'
+ operationId: create-service-database-import
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Service UUID.'
+ required: true
+ schema:
+ type: string
+ -
+ name: database_uuid
+ in: path
+ description: 'Service database UUID.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DatabaseImportRequest'
+ responses:
+ '202':
+ description: 'Import queued'
+ '409':
+ description: 'Import already active'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/services/{uuid}/databases/{database_uuid}/imports/{activity_id}':
+ get:
+ tags:
+ - 'Service databases'
+ summary: 'Get service database import status'
+ operationId: get-service-database-import
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Service UUID.'
+ required: true
+ schema:
+ type: string
+ -
+ name: database_uuid
+ in: path
+ description: 'Service database UUID.'
+ required: true
+ schema:
+ type: string
+ -
+ name: activity_id
+ in: path
+ description: 'Import activity ID.'
+ required: true
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: 'Import status'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DatabaseImportStatus'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
'/services/{uuid}/databases':
get:
tags:
@@ -13605,6 +13999,106 @@ paths:
bearerAuth: []
components:
schemas:
+ DatabaseImportRequest:
+ type: object
+ oneOf:
+ -
+ required:
+ - source
+ - upload_id
+ properties:
+ source:
+ type: string
+ enum:
+ - upload
+ upload_id:
+ type: string
+ format: uuid
+ dump_all:
+ type: boolean
+ default: false
+ replace_existing:
+ description: 'Drop matching PostgreSQL objects before restoring a single-database archive.'
+ type: boolean
+ default: false
+ type: object
+ additionalProperties: false
+ -
+ required:
+ - source
+ - s3_storage_uuid
+ - path
+ properties:
+ source:
+ type: string
+ enum:
+ - s3
+ s3_storage_uuid:
+ type: string
+ path:
+ type: string
+ dump_all:
+ type: boolean
+ default: false
+ replace_existing:
+ description: 'Drop matching PostgreSQL objects before restoring a single-database archive.'
+ type: boolean
+ default: false
+ type: object
+ additionalProperties: false
+ -
+ required:
+ - source
+ - path
+ properties:
+ source:
+ type: string
+ enum:
+ - server
+ path:
+ type: string
+ example: /var/backups/database.sql.gz
+ dump_all:
+ type: boolean
+ default: false
+ replace_existing:
+ description: 'Drop matching PostgreSQL objects before restoring a single-database archive.'
+ type: boolean
+ default: false
+ type: object
+ additionalProperties: false
+ DatabaseImportStatus:
+ properties:
+ id:
+ type: integer
+ status:
+ type: string
+ enum:
+ - queued
+ - in_progress
+ - finished
+ - error
+ - killed
+ - cancelled
+ - closed
+ exit_code:
+ type:
+ - integer
+ - 'null'
+ output:
+ type: string
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ finished_at:
+ type:
+ - string
+ - 'null'
+ format: date-time
+ type: object
VolumeBackupScheduleRequest:
required:
- frequency
@@ -13663,7 +14157,7 @@ components:
minimum: 0
timeout:
type: integer
- default: 3600
+ default: 36000
maximum: 36000
minimum: 60
type: object
@@ -14433,6 +14927,8 @@ components:
type: integer
deployment_queue_limit:
type: integer
+ backup_compression_cpu_percentage:
+ type: integer
dynamic_timeout:
type: integer
force_disabled:
@@ -14455,6 +14951,20 @@ components:
type: boolean
is_metrics_enabled:
type: boolean
+ is_traffic_analytics_enabled:
+ type: boolean
+ traffic_topn:
+ type: integer
+ traffic_sample_threshold:
+ type: integer
+ traffic_retention_1h_days:
+ type: integer
+ traffic_retention_1d_days:
+ type: integer
+ is_geoip_enabled:
+ type: boolean
+ geoip_refresh_days:
+ type: integer
is_reachable:
type: boolean
is_sentinel_enabled:
@@ -14508,6 +15018,22 @@ components:
connection_timeout:
type: integer
description: 'SSH connection timeout in seconds.'
+ docker_version:
+ type: string
+ nullable: true
+ description: 'Detected Docker Engine version on the server.'
+ docker_version_checked_at:
+ type: string
+ nullable: true
+ description: 'When Docker Engine version was last detected.'
+ compose_version:
+ type: string
+ nullable: true
+ description: 'Detected Docker Compose plugin version on the server.'
+ compose_version_checked_at:
+ type: string
+ nullable: true
+ description: 'When Docker Compose version was last detected.'
type: object
Service:
description: 'Service model'
@@ -14737,6 +15263,9 @@ components:
description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.'
scheme: bearer
tags:
+ -
+ name: 'Secret Managers'
+ description: 'Secret Managers'
-
name: Applications
description: Applications
@@ -14767,6 +15296,9 @@ tags:
-
name: Hetzner
description: Hetzner
+ -
+ name: Settings
+ description: Settings
-
name: Notifications
description: Notifications
diff --git a/package-lock.json b/package-lock.json
index d8cb35e61c..41c4740165 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"@tailwindcss/typography": "0.5.20",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
+ "cobe": "^2.0.1",
"playwright": "^1.58.2",
"tw-animate-css": "^1.4.0"
},
@@ -697,6 +698,12 @@
"node": ">=6"
}
},
+ "node_modules/cobe": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/cobe/-/cobe-2.0.1.tgz",
+ "integrity": "sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag==",
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
diff --git a/package.json b/package.json
index 42f39d29ea..a46a750d46 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"@tailwindcss/typography": "0.5.20",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
+ "cobe": "^2.0.1",
"playwright": "^1.58.2",
"tw-animate-css": "^1.4.0"
}
diff --git a/public/svgs/oidc.svg b/public/svgs/oidc.svg
new file mode 100644
index 0000000000..9c542584ef
--- /dev/null
+++ b/public/svgs/oidc.svg
@@ -0,0 +1,5 @@
+
+ OpenID Connect
+
+
+
diff --git a/resources/css/app.css b/resources/css/app.css
index f9965994ec..a8eef8e2d7 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -98,7 +98,7 @@
.button.button-highlighted:not(:disabled),
.button[isHighlighted]:not(:disabled) {
- --button-depth-color: color-mix(in oklab, var(--color-coollabs) 52%, black);
+ --button-depth-color: var(--color-coollabs-300);
}
.dark .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled) {
@@ -168,6 +168,50 @@ select,
transition-duration: 120ms;
}
+/*
+ Traffic-analytics chart tokens (light defaults; dark overrides below).
+ One source of truth shared by ApexCharts donuts, the geo choropleth SVG,
+ and proportional bars β JS reads them at runtime via getComputedStyle.
+ Palette validated with the dataviz skill; contrast ratios are recorded in
+ the PR description. See resources/views/livewire/traffic/_geo.blade.php.
+*/
+:root {
+ /* Categorical HTTP status palette (labelled 2xx/3xx/4xx/5xx in every legend). */
+ --chart-status-2xx: #15803d;
+ --chart-status-3xx: #2563eb;
+ --chart-status-4xx: #d97706;
+ --chart-status-5xx: #dc2626;
+
+ /* KPI sparkline accent for Bandwidth (violet β distinct from the status hues). */
+ --chart-spark-bandwidth: #7c3aed;
+
+ /* Sequential 5-step geo ramp (low -> high traffic) + neutral empty. */
+ --chart-geo-1: #3b82f6;
+ --chart-geo-2: #2563eb;
+ --chart-geo-3: #1d4ed8;
+ --chart-geo-4: #1e40af;
+ --chart-geo-5: #172554;
+ --chart-geo-empty: #e5e7eb;
+ --chart-geo-stroke: #ffffff;
+}
+
+.dark {
+ --chart-status-2xx: #22c55e;
+ --chart-status-3xx: #3b82f6;
+ --chart-status-4xx: #f59e0b;
+ --chart-status-5xx: #ef4444;
+
+ --chart-spark-bandwidth: #a78bfa;
+
+ --chart-geo-1: #2563eb;
+ --chart-geo-2: #3b82f6;
+ --chart-geo-3: #60a5fa;
+ --chart-geo-4: #93c5fd;
+ --chart-geo-5: #bfdbfe;
+ --chart-geo-empty: #262626;
+ --chart-geo-stroke: #101010;
+}
+
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
@@ -2262,7 +2306,7 @@ html[data-theme="custom"] textarea:disabled {
outline: none;
position: relative;
z-index: 1;
- box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-accent) 40%, transparent);
+ box-shadow: 0 0 0 1px var(--color-accent);
}
.application-heading-actions .split-action-main,
@@ -3925,6 +3969,232 @@ html[data-theme="custom"] .logs-viewer-timestamp {
color: var(--color-fg-dim);
}
+.runtime-log-panel {
+ --runtime-log-line: rgba(0, 0, 0, 0.08);
+ --runtime-log-muted: #66666f;
+ --runtime-log-hover: rgba(0, 0, 0, 0.04);
+ --runtime-log-detail: rgba(0, 0, 0, 0.03);
+ --runtime-log-columns: 12.75rem 5.5rem minmax(0, 1fr);
+}
+
+.dark .runtime-log-panel {
+ --runtime-log-line: var(--glass-line, rgba(255, 255, 255, 0.065));
+ --runtime-log-muted: #a09da5;
+ --runtime-log-hover: rgba(255, 255, 255, 0.035);
+ --runtime-log-detail: rgba(0, 0, 0, 0.24);
+}
+
+.runtime-log-viewport.logs-viewer-viewport {
+ container: runtime-log-explorer / inline-size;
+ padding: 0;
+}
+
+.runtime-log-viewport.logs-viewer-viewport::after {
+ display: none;
+}
+
+.runtime-log-columns,
+.runtime-log-viewport [data-log-line]:not(.hidden) {
+ display: grid;
+ grid-template-columns: var(--runtime-log-columns);
+ gap: 0.625rem;
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 2.75rem;
+ align-items: center;
+ padding: 0.6875rem 1.875rem 0.6875rem 1rem;
+}
+
+.runtime-log-columns {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ border-bottom: 1px solid var(--runtime-log-line);
+ background: #f0eff2;
+ color: var(--runtime-log-muted);
+ font-family: ui-sans-serif, system-ui, sans-serif;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.dark .runtime-log-columns {
+ background: var(--coollabs-elevated);
+}
+
+html[data-theme="custom"] .runtime-log-columns {
+ background: var(--color-log-toolbar);
+}
+
+.runtime-log-viewport [data-log-line] {
+ position: relative;
+ border-bottom: 1px solid var(--runtime-log-line);
+ border-radius: 0;
+ cursor: pointer;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ font-size: 0.8125rem;
+ line-height: 1.6;
+}
+
+.runtime-log-viewport [data-log-line]:hover,
+.runtime-log-viewport [data-log-line][aria-expanded="true"] {
+ background: var(--runtime-log-hover);
+}
+
+.runtime-log-viewport [data-log-line]:focus-visible {
+ outline: 2px solid var(--color-accent, #b93642);
+ outline-offset: -2px;
+}
+
+.runtime-log-viewport [data-log-line]::before {
+ content: "[" attr(data-log-level) "]";
+ grid-column: 2;
+ grid-row: 1;
+ color: var(--runtime-log-muted);
+ text-transform: uppercase;
+}
+
+.runtime-log-viewport [data-log-line]::after {
+ content: "";
+ position: absolute;
+ top: 1rem;
+ right: 0.75rem;
+ width: 0.375rem;
+ height: 0.375rem;
+ border-right: 1.5px solid var(--runtime-log-muted);
+ border-bottom: 1.5px solid var(--runtime-log-muted);
+ transform: rotate(45deg);
+}
+
+.runtime-log-viewport [data-log-line][aria-expanded="true"]::after {
+ top: 1.1875rem;
+ transform: rotate(225deg);
+}
+
+.runtime-log-viewport .log-error::before { color: #e55e73; }
+.runtime-log-viewport .log-warning::before { color: #d79945; }
+.runtime-log-viewport .log-debug::before { color: #929099; }
+.runtime-log-viewport .log-info::before { color: #8891f0; }
+
+.runtime-log-viewport .logs-viewer-timestamp {
+ grid-column: 1;
+ grid-row: 1;
+ color: var(--runtime-log-muted);
+ font-size: 0.8125rem;
+ line-height: 1.6;
+ white-space: nowrap;
+}
+
+.runtime-log-viewport [data-line-text] {
+ grid-column: 3;
+ grid-row: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.runtime-log-detail {
+ margin: 0 0 0.25rem;
+ padding: 1.25rem;
+ border-bottom: 1px solid var(--runtime-log-line);
+ background: var(--runtime-log-detail);
+ color: inherit;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ font-size: 0.8125rem;
+ line-height: 1.8;
+}
+
+.dark .runtime-log-detail {
+ color: #adbdc9;
+}
+
+.runtime-log-viewport [data-log-line].hidden + .runtime-log-detail {
+ display: none !important;
+}
+
+.runtime-log-without-time {
+ --runtime-log-columns: 5.5rem minmax(0, 1fr);
+}
+
+.runtime-log-without-time [data-log-line]::before {
+ grid-column: 1;
+}
+
+.runtime-log-without-time [data-line-text] {
+ grid-column: 2;
+}
+
+@container runtime-log-explorer (max-width: 650px) {
+ .runtime-log-columns,
+ .runtime-log-viewport [data-log-line]:not(.hidden) {
+ grid-template-columns: minmax(0, 1fr) 5.5rem;
+ gap: 0.1875rem 0.5rem;
+ }
+
+ .runtime-log-columns > :last-child,
+ .runtime-log-viewport [data-line-text] {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ }
+
+ .runtime-log-without-time [data-line-text] {
+ grid-column: 1 / -1;
+ }
+}
+
+.runtime-log-empty {
+ display: flex;
+ min-height: 18rem;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ padding: 2rem;
+ color: var(--runtime-log-muted);
+ text-align: left;
+}
+
+.runtime-log-loading {
+ min-height: 18rem;
+ align-items: center;
+ justify-content: center;
+ gap: 0.625rem;
+ padding: 2rem;
+ color: var(--runtime-log-muted);
+ font-size: 0.8125rem;
+ font-weight: 500;
+}
+
+.runtime-log-empty-icon {
+ display: inline-flex;
+ width: 2.25rem;
+ height: 2.25rem;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--runtime-log-line);
+ border-radius: 0.5rem;
+ background: var(--runtime-log-detail);
+}
+
+.runtime-log-empty p {
+ color: inherit;
+ font-size: 0.8125rem;
+ font-weight: 500;
+}
+
+.runtime-log-empty div > span {
+ display: block;
+ margin-top: 0.125rem;
+ font-size: 0.75rem;
+ line-height: 1.25rem;
+}
+
+.env-table-detail {
+ padding: 0.25rem 1rem 1.25rem;
+}
+
/* Small pill badges for table cells */
.table-badge {
display: inline-flex;
diff --git a/resources/js/app.js b/resources/js/app.js
index 10d3c2d01c..2e0056011b 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,4 +1,6 @@
+import { initializeCopyButtonComponent } from './copy-button.js';
import { initializeTerminalComponent } from './terminal.js';
+import './traffic-globe.js';
import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js';
document.addEventListener('livewire:init', () => {
@@ -17,6 +19,7 @@ document.addEventListener('livewire:navigated', () => {
// Keeping this registration independent from the current route also makes it
// available before Alpine processes terminal markup after wire:navigate.
document.addEventListener('alpine:init', initializeTerminalComponent);
+document.addEventListener('alpine:init', initializeCopyButtonComponent);
/**
* Smooth-scroll a settings section into view, then flash its border for 500ms
diff --git a/resources/js/copy-button.js b/resources/js/copy-button.js
new file mode 100644
index 0000000000..0ce8d5d67d
--- /dev/null
+++ b/resources/js/copy-button.js
@@ -0,0 +1,35 @@
+// Alpine data provider for the component (x-data="copyButton").
+export function initializeCopyButtonComponent() {
+ window.Alpine.data('copyButton', () => ({
+ copied: false,
+ async copy(value) {
+ if (value === null || value === undefined) {
+ window.toast('Value is not available.', { type: 'warning' });
+ return;
+ }
+ try {
+ if (navigator.clipboard?.writeText && window.isSecureContext) {
+ await navigator.clipboard.writeText(value);
+ } else {
+ // Deprecated, but the only copy path on plain http (non-secure contexts).
+ const textarea = document.createElement('textarea');
+ textarea.value = value;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.left = '-9999px';
+ document.body.appendChild(textarea);
+ textarea.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(textarea);
+ if (!ok) {
+ throw new Error('Copy command was rejected.');
+ }
+ }
+ this.copied = true;
+ setTimeout(() => (this.copied = false), 1200);
+ } catch (e) {
+ window.toast('Could not copy to clipboard.', { type: 'warning' });
+ }
+ },
+ }));
+}
diff --git a/resources/js/traffic-globe.js b/resources/js/traffic-globe.js
new file mode 100644
index 0000000000..49f52ac9ac
--- /dev/null
+++ b/resources/js/traffic-globe.js
@@ -0,0 +1,239 @@
+import createGlobe from 'cobe';
+
+// ISO-3166 alpha-2 -> [lat, lng] centroids (Google public-data canonical set).
+// Used to place request-volume markers on the interactive globe. Kept inline so
+// the globe has zero runtime fetch dependency.
+const CENTROIDS = {"AD":[42.546245,1.601554],"AE":[23.424076,53.847818],"AF":[33.93911,67.709953],"AG":[17.060816,-61.796428],"AI":[18.220554,-63.068615],"AL":[41.153332,20.168331],"AM":[40.069099,45.038189],"AN":[12.226079,-69.060087],"AO":[-11.202692,17.873887],"AQ":[-75.250973,-0.071389],"AR":[-38.416097,-63.616672],"AS":[-14.270972,-170.132217],"AT":[47.516231,14.550072],"AU":[-25.274398,133.775136],"AW":[12.52111,-69.968338],"AZ":[40.143105,47.576927],"BA":[43.915886,17.679076],"BB":[13.193887,-59.543198],"BD":[23.684994,90.356331],"BE":[50.503887,4.469936],"BF":[12.238333,-1.561593],"BG":[42.733883,25.48583],"BH":[25.930414,50.637772],"BI":[-3.373056,29.918886],"BJ":[9.30769,2.315834],"BM":[32.321384,-64.75737],"BN":[4.535277,114.727669],"BO":[-16.290154,-63.588653],"BR":[-14.235004,-51.92528],"BS":[25.03428,-77.39628],"BT":[27.514162,90.433601],"BV":[-54.423199,3.413194],"BW":[-22.328474,24.684866],"BY":[53.709807,27.953389],"BZ":[17.189877,-88.49765],"CA":[56.130366,-106.346771],"CC":[-12.164165,96.870956],"CD":[-4.038333,21.758664],"CF":[6.611111,20.939444],"CG":[-0.228021,15.827659],"CH":[46.818188,8.227512],"CI":[7.539989,-5.54708],"CK":[-21.236736,-159.777671],"CL":[-35.675147,-71.542969],"CM":[7.369722,12.354722],"CN":[35.86166,104.195397],"CO":[4.570868,-74.297333],"CR":[9.748917,-83.753428],"CU":[21.521757,-77.781167],"CV":[16.002082,-24.013197],"CX":[-10.447525,105.690449],"CY":[35.126413,33.429859],"CZ":[49.817492,15.472962],"DE":[51.165691,10.451526],"DJ":[11.825138,42.590275],"DK":[56.26392,9.501785],"DM":[15.414999,-61.370976],"DO":[18.735693,-70.162651],"DZ":[28.033886,1.659626],"EC":[-1.831239,-78.183406],"EE":[58.595272,25.013607],"EG":[26.820553,30.802498],"EH":[24.215527,-12.885834],"ER":[15.179384,39.782334],"ES":[40.463667,-3.74922],"ET":[9.145,40.489673],"FI":[61.92411,25.748151],"FJ":[-16.578193,179.414413],"FK":[-51.796253,-59.523613],"FM":[7.425554,150.550812],"FO":[61.892635,-6.911806],"FR":[46.227638,2.213749],"GA":[-0.803689,11.609444],"GB":[55.378051,-3.435973],"GD":[12.262776,-61.604171],"GE":[42.315407,43.356892],"GF":[3.933889,-53.125782],"GG":[49.465691,-2.585278],"GH":[7.946527,-1.023194],"GI":[36.137741,-5.345374],"GL":[71.706936,-42.604303],"GM":[13.443182,-15.310139],"GN":[9.945587,-9.696645],"GP":[16.995971,-62.067641],"GQ":[1.650801,10.267895],"GR":[39.074208,21.824312],"GS":[-54.429579,-36.587909],"GT":[15.783471,-90.230759],"GU":[13.444304,144.793731],"GW":[11.803749,-15.180413],"GY":[4.860416,-58.93018],"GZ":[31.354676,34.308825],"HK":[22.396428,114.109497],"HM":[-53.08181,73.504158],"HN":[15.199999,-86.241905],"HR":[45.1,15.2],"HT":[18.971187,-72.285215],"HU":[47.162494,19.503304],"ID":[-0.789275,113.921327],"IE":[53.41291,-8.24389],"IL":[31.046051,34.851612],"IM":[54.236107,-4.548056],"IN":[20.593684,78.96288],"IO":[-6.343194,71.876519],"IQ":[33.223191,43.679291],"IR":[32.427908,53.688046],"IS":[64.963051,-19.020835],"IT":[41.87194,12.56738],"JE":[49.214439,-2.13125],"JM":[18.109581,-77.297508],"JO":[30.585164,36.238414],"JP":[36.204824,138.252924],"KE":[-0.023559,37.906193],"KG":[41.20438,74.766098],"KH":[12.565679,104.990963],"KI":[-3.370417,-168.734039],"KM":[-11.875001,43.872219],"KN":[17.357822,-62.782998],"KP":[40.339852,127.510093],"KR":[35.907757,127.766922],"KW":[29.31166,47.481766],"KY":[19.513469,-80.566956],"KZ":[48.019573,66.923684],"LA":[19.85627,102.495496],"LB":[33.854721,35.862285],"LC":[13.909444,-60.978893],"LI":[47.166,9.555373],"LK":[7.873054,80.771797],"LR":[6.428055,-9.429499],"LS":[-29.609988,28.233608],"LT":[55.169438,23.881275],"LU":[49.815273,6.129583],"LV":[56.879635,24.603189],"LY":[26.3351,17.228331],"MA":[31.791702,-7.09262],"MC":[43.750298,7.412841],"MD":[47.411631,28.369885],"ME":[42.708678,19.37439],"MG":[-18.766947,46.869107],"MH":[7.131474,171.184478],"MK":[41.608635,21.745275],"ML":[17.570692,-3.996166],"MM":[21.913965,95.956223],"MN":[46.862496,103.846656],"MO":[22.198745,113.543873],"MP":[17.33083,145.38469],"MQ":[14.641528,-61.024174],"MR":[21.00789,-10.940835],"MS":[16.742498,-62.187366],"MT":[35.937496,14.375416],"MU":[-20.348404,57.552152],"MV":[3.202778,73.22068],"MW":[-13.254308,34.301525],"MX":[23.634501,-102.552784],"MY":[4.210484,101.975766],"MZ":[-18.665695,35.529562],"NA":[-22.95764,18.49041],"NC":[-20.904305,165.618042],"NE":[17.607789,8.081666],"NF":[-29.040835,167.954712],"NG":[9.081999,8.675277],"NI":[12.865416,-85.207229],"NL":[52.132633,5.291266],"NO":[60.472024,8.468946],"NP":[28.394857,84.124008],"NR":[-0.522778,166.931503],"NU":[-19.054445,-169.867233],"NZ":[-40.900557,174.885971],"OM":[21.512583,55.923255],"PA":[8.537981,-80.782127],"PE":[-9.189967,-75.015152],"PF":[-17.679742,-149.406843],"PG":[-6.314993,143.95555],"PH":[12.879721,121.774017],"PK":[30.375321,69.345116],"PL":[51.919438,19.145136],"PM":[46.941936,-56.27111],"PN":[-24.703615,-127.439308],"PR":[18.220833,-66.590149],"PS":[31.952162,35.233154],"PT":[39.399872,-8.224454],"PW":[7.51498,134.58252],"PY":[-23.442503,-58.443832],"QA":[25.354826,51.183884],"RE":[-21.115141,55.536384],"RO":[45.943161,24.96676],"RS":[44.016521,21.005859],"RU":[61.52401,105.318756],"RW":[-1.940278,29.873888],"SA":[23.885942,45.079162],"SB":[-9.64571,160.156194],"SC":[-4.679574,55.491977],"SD":[12.862807,30.217636],"SE":[60.128161,18.643501],"SG":[1.352083,103.819836],"SH":[-24.143474,-10.030696],"SI":[46.151241,14.995463],"SJ":[77.553604,23.670272],"SK":[48.669026,19.699024],"SL":[8.460555,-11.779889],"SM":[43.94236,12.457777],"SN":[14.497401,-14.452362],"SO":[5.152149,46.199616],"SR":[3.919305,-56.027783],"ST":[0.18636,6.613081],"SV":[13.794185,-88.89653],"SY":[34.802075,38.996815],"SZ":[-26.522503,31.465866],"TC":[21.694025,-71.797928],"TD":[15.454166,18.732207],"TF":[-49.280366,69.348557],"TG":[8.619543,0.824782],"TH":[15.870032,100.992541],"TJ":[38.861034,71.276093],"TK":[-8.967363,-171.855881],"TL":[-8.874217,125.727539],"TM":[38.969719,59.556278],"TN":[33.886917,9.537499],"TO":[-21.178986,-175.198242],"TR":[38.963745,35.243322],"TT":[10.691803,-61.222503],"TV":[-7.109535,177.64933],"TW":[23.69781,120.960515],"TZ":[-6.369028,34.888822],"UA":[48.379433,31.16558],"UG":[1.373333,32.290275],"US":[37.09024,-95.712891],"UY":[-32.522779,-55.765835],"UZ":[41.377491,64.585262],"VA":[41.902916,12.453389],"VC":[12.984305,-61.287228],"VE":[6.42375,-66.58973],"VG":[18.420695,-64.639968],"VI":[18.335765,-64.896335],"VN":[14.058324,108.277199],"VU":[-15.376706,166.959158],"WF":[-13.768752,-177.156097],"WS":[-13.759029,-172.104629],"XK":[42.602636,20.902977],"YE":[15.552727,48.516388],"YT":[-12.8275,45.166244],"ZA":[-30.559482,22.937506],"ZM":[-13.133897,27.849332],"ZW":[-19.015438,29.154857]};
+
+// Palettes for the dotted globe, tuned to the analytics chart tokens (blue markers).
+const THEMES = {
+ dark: {
+ dark: 1,
+ baseColor: [0.45, 0.5, 0.62],
+ markerColor: [0.36, 0.6, 1],
+ glowColor: [0.12, 0.16, 0.26],
+ mapBrightness: 11,
+ },
+ light: {
+ dark: 0,
+ baseColor: [0.82, 0.85, 0.9],
+ markerColor: [0.13, 0.36, 0.92],
+ glowColor: [1, 1, 1],
+ mapBrightness: 9,
+ },
+};
+
+/**
+ * Turn country-breakdown rows into cobe markers. Marker size scales with the
+ * square root of request volume so a single dominant country doesn't dwarf the rest.
+ *
+ * @param {Array<{code: string, requests: number}>} data
+ * @returns {Array<{location: [number, number], size: number}>}
+ */
+function buildMarkers(data) {
+ const rows = (data || [])
+ .map((r) => ({ code: String(r.code || '').toUpperCase(), requests: Number(r.requests || 0) }))
+ .filter((r) => r.requests > 0 && CENTROIDS[r.code]);
+
+ if (rows.length === 0) {
+ return [];
+ }
+
+ const max = Math.max(...rows.map((r) => r.requests));
+
+ return rows.map((r) => ({
+ location: CENTROIDS[r.code],
+ size: Math.max(0.03, Math.min(0.11, Math.sqrt(r.requests / max) * 0.11)),
+ }));
+}
+
+const TWO_PI = Math.PI * 2;
+
+// cobe orientation for a lat/lng so the point faces the viewer (cobe's own
+// focus example formula). Returns [phi, theta].
+function locationToAngles(lat, lng) {
+ return [Math.PI - ((lng * Math.PI) / 180 - Math.PI / 2), (lat * Math.PI) / 180];
+}
+
+// Shortest-path angular interpolation, so easing across the 0/2Ο seam never
+// spins the long way around.
+function lerpAngle(current, target, t) {
+ let delta = ((target - current + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI;
+
+ return current + delta * t;
+}
+
+/**
+ * Mount an interactive, drag-to-rotate dotted globe onto a canvas. Returns a
+ * controller with `update(data, dark)`, `focus(code)`, `resume()` and
+ * `destroy()`. The globe auto-rotates, pauses while grabbed or while a country
+ * is hover-focused, and eases smoothly toward whatever it's pointed at.
+ *
+ * cobe v2 has no internal render loop or `onRender` callback: createGlobe draws
+ * a single frame and returns `{ update, destroy }`. We drive our own rAF loop,
+ * calling `globe.update({...})` each frame for rotation and to swap markers/theme.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * @param {Array<{code: string, requests: number}>} data
+ * @param {boolean} dark
+ */
+function mountTrafficGlobe(canvas, data, dark) {
+ let globe = null;
+ let width = 0;
+ let destroyed = false;
+ let rafId = 0;
+ let markers = buildMarkers(data);
+
+ // Ambient spin is decorative; honor reduced-motion by not auto-rotating
+ // (drag + hover-focus still work β those are user-initiated).
+ const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ // Rotation is a single moving target the loop eases toward each frame.
+ let targetPhi = 0;
+ let currentPhi = 0;
+ let targetTheta = 0.2;
+ let currentTheta = 0.2;
+ let autoRotate = !prefersReduced;
+
+ let dragging = null; // clientX at pointerdown
+ let dragStartPhi = 0;
+
+ const onPointerDown = (e) => {
+ dragging = e.clientX;
+ dragStartPhi = targetPhi;
+ autoRotate = false;
+ canvas.style.cursor = 'grabbing';
+ };
+ const onPointerUp = () => {
+ if (dragging === null) {
+ return;
+ }
+ dragging = null;
+ autoRotate = ! prefersReduced;
+ canvas.style.cursor = 'grab';
+ };
+ const onPointerMove = (e) => {
+ if (dragging !== null) {
+ targetPhi = dragStartPhi + (e.clientX - dragging) / 150;
+ }
+ };
+
+ canvas.addEventListener('pointerdown', onPointerDown);
+ window.addEventListener('pointerup', onPointerUp);
+ window.addEventListener('pointermove', onPointerMove);
+ canvas.style.cursor = 'grab';
+
+ const create = (isDark) => {
+ const theme = isDark ? THEMES.dark : THEMES.light;
+
+ globe = createGlobe(canvas, {
+ devicePixelRatio: 2,
+ width: width,
+ height: width,
+ phi: currentPhi,
+ theta: currentTheta,
+ diffuse: 1.2,
+ mapSamples: 16000,
+ mapBrightness: theme.mapBrightness,
+ dark: theme.dark,
+ baseColor: theme.baseColor,
+ markerColor: theme.markerColor,
+ glowColor: theme.glowColor,
+ opacity: 0.92,
+ markers: markers,
+ });
+ };
+
+ // Self-driven animation loop (cobe v2 draws only when we call update()).
+ const tick = () => {
+ if (destroyed) {
+ return;
+ }
+ if (globe && width > 0) {
+ if (autoRotate && dragging === null) {
+ targetPhi += 0.0025;
+ }
+ currentPhi = lerpAngle(currentPhi, targetPhi, 0.12);
+ currentTheta += (targetTheta - currentTheta) * 0.12;
+ globe.update({ phi: currentPhi, theta: currentTheta, width: width, height: width, markers });
+ }
+ rafId = requestAnimationFrame(tick);
+ };
+
+ // cobe needs a non-zero canvas width at creation; inside a freshly-rendered
+ // or momentarily-hidden container offsetWidth can be 0, which yields a blank
+ // globe (only the grab cursor shows). Create once a real width is known.
+ const ensure = () => {
+ const next = canvas.offsetWidth;
+ if (destroyed || next === 0 || globe) {
+ return;
+ }
+ width = next;
+ create(dark);
+ rafId = requestAnimationFrame(tick);
+ };
+
+ const resizeObserver = new ResizeObserver(() => {
+ if (globe) {
+ width = canvas.offsetWidth || width;
+ } else {
+ ensure();
+ }
+ });
+ resizeObserver.observe(canvas);
+ requestAnimationFrame(ensure);
+
+ return {
+ update(newData, isDark) {
+ if (destroyed) {
+ return;
+ }
+ data = newData;
+ dark = isDark;
+ markers = buildMarkers(newData);
+ if (globe) {
+ const theme = isDark ? THEMES.dark : THEMES.light;
+ globe.update({
+ markers,
+ dark: theme.dark,
+ mapBrightness: theme.mapBrightness,
+ baseColor: theme.baseColor,
+ markerColor: theme.markerColor,
+ glowColor: theme.glowColor,
+ });
+ }
+ },
+ focus(code) {
+ const c = CENTROIDS[String(code || '').toUpperCase()];
+ if (!c) {
+ return;
+ }
+ const [phi, theta] = locationToAngles(c[0], c[1]);
+ targetPhi = phi;
+ targetTheta = theta;
+ autoRotate = false;
+ },
+ resume() {
+ if (dragging === null) {
+ autoRotate = ! prefersReduced;
+ }
+ },
+ destroy() {
+ destroyed = true;
+ if (rafId) {
+ cancelAnimationFrame(rafId);
+ rafId = 0;
+ }
+ resizeObserver.disconnect();
+ canvas.removeEventListener('pointerdown', onPointerDown);
+ window.removeEventListener('pointerup', onPointerUp);
+ window.removeEventListener('pointermove', onPointerMove);
+ if (globe) {
+ globe.destroy();
+ globe = null;
+ }
+ },
+ };
+}
+
+window.mountTrafficGlobe = mountTrafficGlobe;
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php
index 829a26cad3..12eb57867c 100644
--- a/resources/views/auth/login.blade.php
+++ b/resources/views/auth/login.blade.php
@@ -80,11 +80,15 @@
@if ($enabled_oauth_providers->isNotEmpty())
Or continue with
-
+
@foreach ($enabled_oauth_providers as $provider_setting)
- {{ __("auth.login.$provider_setting->provider") }}
+ @if ($provider_setting->provider !== 'oidc')
+
+ @endif
+ {{ $provider_setting->loginLabel() }}
@endforeach
diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php
index 9a1405affb..604513b269 100644
--- a/resources/views/components/application/configuration-sidebar.blade.php
+++ b/resources/views/components/application/configuration-sidebar.blade.php
@@ -115,6 +115,11 @@
'route' => 'project.application.metrics',
'active' => $currentRoute === 'project.application.metrics',
],
+ [
+ 'label' => 'Analytics',
+ 'route' => 'project.application.analytics',
+ 'active' => $currentRoute === 'project.application.analytics',
+ ],
[
'label' => 'Tags',
'route' => 'project.application.tags',
@@ -154,6 +159,7 @@
'Resource Limits' => 'cpu',
'Resource Operations' => 'server-update',
'Metrics' => 'graph',
+ 'Analytics' => 'analytics',
'Tags' => 'tags',
'Danger Zone' => 'shield-alert',
];
@@ -161,7 +167,7 @@
// Discord-style groups for the settings sidebar
$menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'],
- 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics'],
+ 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics', 'Analytics'],
'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'],
diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php
index dfdceef20b..3333a62bfa 100644
--- a/resources/views/components/copy-button.blade.php
+++ b/resources/views/components/copy-button.blade.php
@@ -1,22 +1,20 @@
@props([
- 'value',
+ 'value' => null,
+ 'resolve' => null,
'label' => 'Copy to clipboard',
])
-
class('inline-flex size-6 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-40 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-white') }}
- title="{{ $label }}" aria-label="{{ $label }}" @disabled(blank($value))>
-
-
-
-
-
-
-
+@php
+ $valueExpression = $resolve ?? \Illuminate\Support\Js::from($value);
+@endphp
+
+class(['icon-button group shrink-0']) }} @disabled($resolve === null && blank($value))
+ x-data="copyButton" @click="copy(await ({{ $valueExpression }}))">
+
+
+
+
diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php
deleted file mode 100644
index e299610eb2..0000000000
--- a/resources/views/components/forms/copy-button.blade.php
+++ /dev/null
@@ -1,28 +0,0 @@
-@props(['text', 'label' => null])
-
-
- @if ($label)
-
{{ $label }}
- @endif
-
-
-
copied = false, 1000)"
- class="copy-button flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 transition-colors hover:text-black focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base"
- title="Copy to clipboard"
- aria-label="Copy to clipboard">
-
-
-
-
-
-
-
-
-
diff --git a/resources/views/components/forms/copy-input.blade.php b/resources/views/components/forms/copy-input.blade.php
new file mode 100644
index 0000000000..d31fac0bca
--- /dev/null
+++ b/resources/views/components/forms/copy-input.blade.php
@@ -0,0 +1,15 @@
+@props(['text', 'label' => null])
+
+
+ @if ($label)
+
{{ $label }}
+ @endif
+
+
+
+
+
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php
index 378a3947e3..41a29fbbdb 100644
--- a/resources/views/components/forms/env-var-input.blade.php
+++ b/resources/views/components/forms/env-var-input.blade.php
@@ -20,13 +20,32 @@
cursorPosition: 0,
currentScope: null,
availableVars: @js($availableVars),
+ hasVaultSource: @js($hasVaultSource),
+ vaultKeysLoading: false,
get availableScopes() {
// Only include scopes that have at least one variable
const allScopes = ['team', 'project', 'environment', 'server'];
- return allScopes.filter(scope => {
+ const scopes = allScopes.filter(scope => {
const vars = this.availableVars[scope];
return vars && vars.length > 0;
});
+ // The vault scope is offered whenever a secret manager source is
+ // configured; its keys are fetched lazily on first use.
+ if (this.hasVaultSource) {
+ scopes.push('vault');
+ }
+ return scopes;
+ },
+ loadVaultKeys() {
+ if (this.vaultKeysLoading) return;
+ this.vaultKeysLoading = true;
+ this.$wire.fetchSecretManagerKeys().then(keys => {
+ this.availableVars['vault'] = keys || [];
+ this.vaultKeysLoading = false;
+ this.handleInput();
+ }).catch(() => {
+ this.vaultKeysLoading = false;
+ });
},
scopeUrls: @js($scopeUrls),
@@ -84,6 +103,15 @@
}
this.currentScope = scope;
+
+ // Vault keys are fetched from the secret manager on first use.
+ if (scope === 'vault' && this.availableVars['vault'] === undefined) {
+ this.loadVaultKeys();
+ this.suggestions = [];
+ this.showDropdown = true;
+ return;
+ }
+
const scopeVars = this.availableVars[scope] || [];
const filtered = scopeVars.filter(v =>
v.toLowerCase().includes((partial || '').toLowerCase())
@@ -214,6 +242,7 @@
wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@endif
wire:loading.attr="disabled"
+ wire:target.except="fetchSecretManagerKeys"
@disabled($disabled)
@if ($type !== 'password')
type="{{ $type }}"
@@ -236,7 +265,14 @@
-
+
+
+ Loading keys from the secret managerβ¦
+ No matching keys in the secret manager.
+
+
+
+
-
-
-
-
-
-
+
+ {{-- Non-selectable group header (options with header: true). Each header is the
+ only child of its x-for wrapper, so `first:` can't target it β the divider
+ is an unconditional top border, which reads as a separator between groups. --}}
+
+
+
+
+
+
+
+
+
+
+
+
@@ -177,16 +188,25 @@
{{ $emptyText }}
-
-
-
-
-
-
+
+ {{-- Non-selectable group header (options with header: true). --}}
+
+
+
+
+
+
+
+
+
+
+
+
@endif
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php
index d16bdbe9a1..6b0624a3bb 100644
--- a/resources/views/components/modal-confirmation.blade.php
+++ b/resources/views/components/modal-confirmation.blade.php
@@ -292,17 +292,8 @@
diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php
index 153727e46d..251628c496 100644
--- a/resources/views/components/navbar.blade.php
+++ b/resources/views/components/navbar.blade.php
@@ -87,6 +87,14 @@
+
+
+
@can('canAccessTerminal')
false,
'options' => [10, 25, 50, 100],
'storageKey' => null,
+ 'canGate' => null,
+ 'canResource' => null,
])
+@php
+ $disabled = $canGate && $canResource
+ && ! Illuminate\Support\Facades\Gate::allows($canGate, $canResource);
+@endphp
+
@@ -43,6 +51,7 @@
@foreach ($options as $option)
{{ $option }}
@@ -50,6 +59,7 @@
@endforeach
Customβ¦
@@ -58,6 +68,6 @@
diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php
index 04471497f5..a611869feb 100644
--- a/resources/views/components/reicon.blade.php
+++ b/resources/views/components/reicon.blade.php
@@ -9,6 +9,7 @@
'fire' => ' ',
'cloud' => ' ',
'code' => ' ',
+ 'analytics' => ' ',
'mail' => ' ',
'dashboard' => ' ',
'projects' => ' ',
@@ -63,7 +64,8 @@
'upload' => ' ',
'x' => ' ',
'check' => ' ',
- 'chevron-down' => ' ',
+ 'copy' => ' ',
+ 'chevron-down' => ' ',
'trash' => ' ',
'external-link' => ' ',
'server-update' => ' ',
diff --git a/resources/views/components/security/settings-layout.blade.php b/resources/views/components/security/settings-layout.blade.php
index d2b3e30a6f..a17b0b96a6 100644
--- a/resources/views/components/security/settings-layout.blade.php
+++ b/resources/views/components/security/settings-layout.blade.php
@@ -12,6 +12,12 @@
'active' => request()->routeIs('security.cloud-tokens*'),
'icon' => 'cloud',
] : null,
+ auth()->user()?->can('viewAny', App\Models\IntegrationToken::class) ? [
+ 'label' => 'Integration Tokens',
+ 'route' => 'security.integration-tokens',
+ 'active' => request()->routeIs('security.integration-tokens'),
+ 'icon' => 'network',
+ ] : null,
auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [
'label' => 'Cloud-Init Scripts',
'route' => 'security.cloud-init-scripts',
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php
index 46e433e8a5..75fb43e2d7 100644
--- a/resources/views/components/server/sidebar.blade.php
+++ b/resources/views/components/server/sidebar.blade.php
@@ -132,6 +132,14 @@
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
+ [
+ 'label' => 'Analytics',
+ 'route' => 'server.analytics',
+ 'active' => $activeMenu === 'analytics',
+ 'icon' => 'analytics',
+ 'group' => 'Operations',
+ 'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->isBuildServer(),
+ ],
[
'label' => 'Security',
'route' => 'server.security.patches',
diff --git a/resources/views/components/settings/sidebar.blade.php b/resources/views/components/settings/sidebar.blade.php
index 0e0de551fd..dbe381e050 100644
--- a/resources/views/components/settings/sidebar.blade.php
+++ b/resources/views/components/settings/sidebar.blade.php
@@ -12,6 +12,24 @@
'active' => $activeMenu === 'advanced',
'icon' => 'grid',
],
+ [
+ 'label' => 'Authentication',
+ 'route' => 'settings.oauth',
+ 'active' => $activeMenu === 'oauth',
+ 'icon' => 'keys',
+ ],
+ [
+ 'label' => 'Transactional Email',
+ 'route' => 'settings.email',
+ 'active' => $activeMenu === 'email',
+ 'icon' => 'notifications',
+ ],
+ [
+ 'label' => 'Instance Backup',
+ 'route' => 'settings.backup',
+ 'active' => $activeMenu === 'backup',
+ 'icon' => 'database',
+ ],
[
'label' => 'Updates',
'route' => 'settings.updates',
diff --git a/resources/views/components/skeleton.blade.php b/resources/views/components/skeleton.blade.php
new file mode 100644
index 0000000000..0ac99a8734
--- /dev/null
+++ b/resources/views/components/skeleton.blade.php
@@ -0,0 +1,6 @@
+{{--
+ Generic shimmer block. Size and shape are passed via `class`, e.g.
+ . Composed by the x-skeleton.* helpers
+ (tiles, table) and by component placeholder() views for lazy-loaded pages.
+--}}
+class(['animate-pulse rounded-md bg-neutral-200/80 dark:bg-white/[0.06]']) }}>
diff --git a/resources/views/components/skeleton/table.blade.php b/resources/views/components/skeleton/table.blade.php
new file mode 100644
index 0000000000..b37969cbb5
--- /dev/null
+++ b/resources/views/components/skeleton/table.blade.php
@@ -0,0 +1,31 @@
+@props([
+ 'rows' => 6,
+ 'flush' => false,
+])
+
+@php
+ // Vary the leading-column widths so rows read as content, not a solid block.
+ $widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3'];
+@endphp
+
+@if ($flush)
+ {{-- Edge-to-edge list rows with dividers, matching a `flush` settings-section list. --}}
+
+ @for ($i = 0; $i < (int) $rows; $i++)
+
+
+
+
+ @endfor
+
+@else
+ {{-- Padded list rows: a label column and a trailing value per row. --}}
+
+ @for ($i = 0; $i < (int) $rows; $i++)
+
+
+
+
+ @endfor
+
+@endif
diff --git a/resources/views/components/skeleton/tiles.blade.php b/resources/views/components/skeleton/tiles.blade.php
new file mode 100644
index 0000000000..d5bc68ca68
--- /dev/null
+++ b/resources/views/components/skeleton/tiles.blade.php
@@ -0,0 +1,21 @@
+@props([
+ 'count' => 5,
+ 'grid' => 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
+ 'rounded' => 'rounded-lg',
+ 'labels' => ['Requests', 'Unique visitors', 'Bandwidth', 'Error rate', 'p95 latency'],
+])
+
+{{-- KPI stat-tile grid skeleton, mirroring an analytics Overview tile grid. --}}
+
+ @for ($i = 0; $i < (int) $count; $i++)
+
+
+ {{ $labels[$i] ?? 'Metric' }}
+
+
+
+
+
+
+ @endfor
+
diff --git a/resources/views/components/split-action.blade.php b/resources/views/components/split-action.blade.php
new file mode 100644
index 0000000000..c27525166c
--- /dev/null
+++ b/resources/views/components/split-action.blade.php
@@ -0,0 +1,19 @@
+class(['split-action relative']) }} x-data="{ open: false }"
+ x-effect="$dispatch('resource-actions-toggled', { open })" @click.outside="open = false"
+ @keydown.escape.window="open = false">
+
attributes->class(['split-action-main']) }}>
+ {{ $main }}
+
+ @if ($slot->hasActualContent())
+
+
+
+
+
+
+ {{ $slot }}
+
+ @endif
+
diff --git a/resources/views/components/team/settings-layout.blade.php b/resources/views/components/team/settings-layout.blade.php
index e9a5fbd9b0..3b6db03c3a 100644
--- a/resources/views/components/team/settings-layout.blade.php
+++ b/resources/views/components/team/settings-layout.blade.php
@@ -12,6 +12,12 @@
'active' => request()->routeIs('team.member.index'),
'icon' => 'teams',
],
+ auth()->user()->isAdminOfTeam(currentTeam()->id) ? [
+ 'label' => 'Audit log',
+ 'route' => 'team.audit-log',
+ 'active' => request()->routeIs('team.audit-log'),
+ 'icon' => 'time-back',
+ ] : null,
isInstanceAdmin() ? [
'label' => 'Admin View',
'route' => 'team.admin-view',
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index ffeefb587f..66201da936 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -314,30 +314,6 @@
let checkHealthInterval = null;
let checkIfIamDeadInterval = null;
- async function copyToClipboard(text) {
- try {
- if (navigator.clipboard?.writeText && window.isSecureContext) {
- await navigator.clipboard.writeText(text);
- } else {
- const textarea = document.createElement('textarea');
- textarea.value = text;
- textarea.setAttribute('readonly', '');
- textarea.style.position = 'fixed';
- textarea.style.left = '-9999px';
- document.body.appendChild(textarea);
- textarea.select();
- const copied = document.execCommand('copy');
- document.body.removeChild(textarea);
- if (!copied) {
- throw new Error('Copy command was rejected.');
- }
- }
- window.Livewire.dispatch('success', 'Copied to clipboard.');
- } catch (error) {
- window.Livewire.dispatch('error', 'Failed to copy to clipboard.');
- }
- }
- window.copyToClipboard = copyToClipboard;
document.addEventListener('livewire:init', () => {
window.Livewire.on('reloadWindow', (timeout) => {
if (timeout) {
diff --git a/resources/views/livewire/analytics-placeholder.blade.php b/resources/views/livewire/analytics-placeholder.blade.php
new file mode 100644
index 0000000000..767f0f46ac
--- /dev/null
+++ b/resources/views/livewire/analytics-placeholder.blade.php
@@ -0,0 +1,59 @@
+
+ @if (! ($hideSkeleton ?? false))
+ {{-- Header (real chrome; only the data below is a skeleton) --}}
+
+ @if (empty($scopedServerUuid ?? null))
+
+
Analytics
+
+ Request traffic across every application and server, reported by Sentinel.
+
+
+ @endif
+
+ {{-- Filter bar --}}
+
+ @if (empty($scopedServerUuid ?? null))
+
+ @endif
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @endif
+
diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php
new file mode 100644
index 0000000000..9b6fd3643b
--- /dev/null
+++ b/resources/views/livewire/analytics.blade.php
@@ -0,0 +1,377 @@
+ 'Referrers',
+ 'browser' => 'Browsers',
+ 'os' => 'Operating systems',
+];
+
+$approxBadge = fn (string $tooltip) => '~ approximate ';
+
+$serverListboxOptions = array_merge(
+ [['value' => '', 'label' => 'All servers']],
+ collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(),
+);
+$appListboxOptions = array_merge(
+ [['value' => '', 'label' => 'All applications']],
+ $appGroupedOptions,
+);
+?>
+
+ @if ($scopedServerUuid === null)
+
+ Analytics | Coolify
+
+ @endif
+
+ {{-- Header --}}
+
+ @if ($scopedServerUuid === null)
+
+
Analytics
+
+ Request traffic across every application and server, reported by Sentinel.
+
+
+ @endif
+
+ @if ($servers->isNotEmpty() && $overview)
+
+ @if ($scopedServerUuid === null)
+
+ @endif
+ {{-- Re-key on the server filter so the application listbox re-initializes with the
+ newly-scoped options (and reset value) instead of showing stale Alpine state. --}}
+
+
+
+ @include('livewire.traffic._live-toggle')
+
+
+ 24 hours
+
+
+
+ 7 days
+
+
+
+ 30 days
+
+
+
+
+
+ @endif
+
+
+ {{-- Nudge: enabled-eligible servers that haven't turned traffic analytics on yet. --}}
+ @if ($scopedServerUuid === null && ! empty($eligibleDisabledServers))
+
+
+
+ {{ count($eligibleDisabledServers) === 1 ? '1 server can start collecting traffic analytics' : count($eligibleDisabledServers).' servers can start collecting traffic analytics' }}
+
+
+ Enabling regenerates the proxy config and restarts the proxy + Sentinel (a brief blip).
+ Works with Traefik & Caddy.
+
+
+
+
+ @endif
+
+ @if ($servers->isEmpty())
+ @if ($scopedServerUuid === null)
+
+
+
+ View servers
+
+
+
+ @endif
+ @elseif (! $overview)
+
+ @else
+ @if ($this->isLivePollable())
+
+ @endif
+
+ {{-- KPIs --}}
+
+
+
+
Requests
+
{{ number_format($overview['requests'] ?? 0) }}
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-requests',
+ 'initial' => $this->requestsSpark(),
+ 'colorVar' => '--chart-status-3xx',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'requestsSpark',
+ ])
+
+
+
+
+ Unique visitors
+ @if ($uniquesApproximate)
+ {!! $approxBadge('Summed across servers; visitors seen on multiple servers may be double-counted.') !!}
+ @endif
+
+
{{ number_format($overview['uniqueVisitors'] ?? 0) }}
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-visitors',
+ 'initial' => $this->uniquesSpark(),
+ 'colorVar' => '--chart-status-2xx',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'uniquesSpark',
+ ])
+
+
+
+
Bandwidth
+
{{ formatBytes($this->bandwidthBytes()) }}
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-bandwidth',
+ 'initial' => $this->bandwidthSpark(),
+ 'colorVar' => '--chart-spark-bandwidth',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'bandwidthSpark',
+ ])
+
+
+
+
Error rate
+
{{ $this->errorRate() }}%
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-errors',
+ 'initial' => $this->errorsSpark(),
+ 'colorVar' => '--chart-status-5xx',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'errorsSpark',
+ ])
+
+
+
+
+ p95 latency
+ @if ($latencyApproximate)
+ {!! $approxBadge('Highest p95 latency across servers; not a true cross-server percentile.') !!}
+ @endif
+
+
{{ number_format($overview['latencyP95'] ?? 0, 1) }} ms
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-latency',
+ 'initial' => $this->latencySpark(),
+ 'colorVar' => '--chart-status-4xx',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'latencySpark',
+ ])
+
+
+
+
+
+ {{-- Requests over time (single area series). --}}
+
+ @include('livewire.traffic._requests-chart')
+
+
+ {{-- Status codes: stacked bar of responses by status class. --}}
+
+ @include('livewire.traffic._status-codes')
+
+
+ {{-- Top applications + Top hosts side by side; Top paths spans full width below.
+ When filtered to one app, only Top paths applies. --}}
+ $appUuid === ''])>
+ @if ($appUuid === '')
+
+ @include('livewire.traffic._hosts-list', ['hosts' => $topHosts])
+
+
+
+ @if (empty($topApps))
+
+ @else
+ @php $maxAppRequests = max(1, (int) collect($topApps)->max('requests')); @endphp
+
+ @foreach ($topApps as $row)
+ @php
+ $appHref = $row['link'] ?? null;
+ $appWidth = min(100, round(($row['requests'] / $maxAppRequests) * 100, 1));
+ @endphp
+ <{{ $appHref ? 'a' : 'div' }} wire:key="analytics-app-{{ $row['uuid'] }}"
+ @if ($appHref) href="{{ $appHref }}" {{ wireNavigate() }} @endif
+ x-show="{{ $loop->index }} >= page * per && {{ $loop->index }} < (page + 1) * per"
+ @class([
+ 'flex min-h-11 items-center gap-3 border-b border-neutral-200 px-4 py-2 last:border-b-0 dark:border-white/[0.07]',
+ 'transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.03]' => $appHref,
+ ])>
+
+ {{ $row['name'] }}
+ @if (! empty($row['domain']))
+ {{ $row['domain'] }}
+ @endif
+
+
+
{{ compactNumber($row['requests']) }}
+
{{ formatBytes($row['bandwidth']) }}
+ @if ($appHref)
+
+
+
+ @endif
+ {{ $appHref ? 'a' : 'div' }}>
+ @endforeach
+ @include('livewire.traffic._pager')
+
+ @endif
+
+ @endif
+
+
+ {{-- Top paths (full width). --}}
+
+ @include('livewire.traffic._paths-list', ['paths' => $topPaths])
+
+
+ {{-- Countries --}}
+
+ @include('livewire.traffic._geo', [
+ 'countries' => data_get($breakdowns, 'country', []),
+ 'attribution' => $attribution,
+ ])
+
+
+ {{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
+
+
+ @php $deviceChart = $this->deviceChartData(); @endphp
+ @include('livewire.traffic._device-chart', [
+ 'labels' => $deviceChart['labels'],
+ 'series' => $deviceChart['series'],
+ ])
+
+
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'protocol',
+ 'label' => 'Top HTTP versions',
+ 'rows' => data_get($breakdowns, 'protocol', []),
+ 'helper' => 'Request volume by negotiated HTTP protocol version.',
+ ])
+
+
+
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'cache',
+ 'label' => 'Top cache statuses',
+ 'rows' => data_get($breakdowns, 'cache', []),
+ 'helper' => 'Reverse-proxy cache outcome (hit, miss, bypass, β¦) by request count.',
+ ])
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'status',
+ 'label' => 'Top status codes',
+ 'rows' => data_get($breakdowns, 'status', []),
+ 'helper' => 'Most frequent HTTP response status codes for the selected range.',
+ ])
+
+
+ {{-- Referrers / browsers / OS / AI agents / IPs, two per row. --}}
+
+ @foreach ($dimensionLabels as $dimension => $label)
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => $dimension,
+ 'label' => $label,
+ 'rows' => data_get($breakdowns, $dimension, []),
+ ])
+ @endforeach
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'agent',
+ 'label' => 'AI agents & bots',
+ 'rows' => data_get($breakdowns, 'agent', []),
+ 'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, β¦) by request count.',
+ ])
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'ip',
+ 'label' => 'Top IPs',
+ 'rows' => data_get($breakdowns, 'ip', []),
+ 'helper' => 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).',
+ ])
+
+
+ {{-- User agents (full width β raw UA strings are long). --}}
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'useragent',
+ 'label' => 'Top user agents',
+ 'rows' => data_get($breakdowns, 'useragent', []),
+ 'helper' => 'Most frequent raw User-Agent strings for the selected range.',
+ ])
+ @endif
+
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php
index f4bf96fbb7..b43075893a 100644
--- a/resources/views/livewire/dashboard.blade.php
+++ b/resources/views/livewire/dashboard.blade.php
@@ -11,11 +11,16 @@
$dashboardItemLimit = 8;
$dashboardProjects = $projects->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
$dashboardServers = $servers->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
+ $hasTrafficAnalytics = $servers->contains(fn ($server) => $server->isTrafficAnalyticsEnabled());
@endphp
+ @if ($hasTrafficAnalytics)
+
+ @endif
+
diff --git a/resources/views/livewire/dashboard/server-metrics-chart.blade.php b/resources/views/livewire/dashboard/server-metrics-chart.blade.php
index aae36747ae..7d6924d46c 100644
--- a/resources/views/livewire/dashboard/server-metrics-chart.blade.php
+++ b/resources/views/livewire/dashboard/server-metrics-chart.blade.php
@@ -92,15 +92,21 @@
const memory = series[1][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const formatPercent = value => Number.isFinite(value) ? `${Number(value.toFixed(1))}%` : 'β';
- const formatTimestamp = timestamp => `${new Date(timestamp).toLocaleString(undefined, {
- timeZone: 'UTC',
+ const formatLocalTimestamp = timestamp => new Date(timestamp).toLocaleString(undefined, {
hour12: false,
- })} UTC`;
+ timeZoneName: 'short',
+ });
+ const formatUtcTimestamp = timestamp => new Date(timestamp).toLocaleString(undefined, {
+ hour12: false,
+ timeZone: 'UTC',
+ timeZoneName: 'short',
+ });
return ``;
},
},
diff --git a/resources/views/livewire/dashboard/traffic-analytics-placeholder.blade.php b/resources/views/livewire/dashboard/traffic-analytics-placeholder.blade.php
new file mode 100644
index 0000000000..de18a27431
--- /dev/null
+++ b/resources/views/livewire/dashboard/traffic-analytics-placeholder.blade.php
@@ -0,0 +1 @@
+
diff --git a/resources/views/livewire/dashboard/traffic-analytics.blade.php b/resources/views/livewire/dashboard/traffic-analytics.blade.php
new file mode 100644
index 0000000000..3cf7ec4b5c
--- /dev/null
+++ b/resources/views/livewire/dashboard/traffic-analytics.blade.php
@@ -0,0 +1,120 @@
+ '~ approximate ';
+
+$spark = 'refreshChartData-'.$chartId.'-status';
+?>
+
+@if ($servers->isNotEmpty() && $overview)
+
+
+
+
+ Traffic analytics
+
+
+ Team-wide request volume across servers with traffic analytics enabled
+
+
+
+
+ @if ($servers->isNotEmpty() && $overview)
+
+
+ 24 hours
+
+
+
+ 7 days
+
+
+
+ 30 days
+
+
+
+ @endif
+
+ Open analytics
+
+
+
+
+
+ {{-- Sparkline KPI cards. Each links through to the full analytics page. --}}
+
+
+ Requests
+ {{ number_format($overview['requests'] ?? 0) }}
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-requests',
+ 'initial' => $this->requestsSpark(),
+ 'colorVar' => '--chart-status-3xx',
+ 'event' => $spark,
+ 'key' => 'requestsSpark',
+ ])
+
+
+
+
+ Unique visitors
+ @if ($uniquesApproximate)
+ {!! $approxBadge('Summed across '.$servers->count().' servers; visitors seen on multiple servers may be double-counted.') !!}
+ @endif
+
+ {{ number_format($overview['uniqueVisitors'] ?? 0) }}
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-visitors',
+ 'initial' => $this->uniquesSpark(),
+ 'colorVar' => '--chart-status-2xx',
+ 'event' => $spark,
+ 'key' => 'uniquesSpark',
+ ])
+
+
+
+ Bandwidth
+ {{ formatBytes($this->bandwidthBytes()) }}
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-bandwidth',
+ 'initial' => $this->bandwidthSpark(),
+ 'colorVar' => '--chart-spark-bandwidth',
+ 'event' => $spark,
+ 'key' => 'bandwidthSpark',
+ ])
+
+
+
+ Error rate
+ {{ $this->errorRate() }}%
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-errors',
+ 'initial' => $this->errorsSpark(),
+ 'colorVar' => '--chart-status-5xx',
+ 'event' => $spark,
+ 'key' => 'errorsSpark',
+ ])
+
+
+
+
+@endif
+
diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php
index 42be93c5d3..32f0421926 100644
--- a/resources/views/livewire/profile/index.blade.php
+++ b/resources/views/livewire/profile/index.blade.php
@@ -134,15 +134,22 @@
+ :disabled="$uses_sso" x-bind:disabled="emailModalOpen || @js($uses_sso)">
Change
-
-
+
+
-
+ @if ($uses_sso)
+
+ Signed in with SSO @if ($sso_provider_label) ({{ $sso_provider_label }}) @endif. Email is managed by your SSO provider.
+
+ @endif
+
+ @if (! $uses_sso)
+
@@ -191,7 +198,8 @@
@endif
-
+
+ @endif
-
-
+
diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php
index c6113808c5..27b40546bb 100644
--- a/resources/views/livewire/project/application/advanced.blade.php
+++ b/resources/views/livewire/project/application/advanced.blade.php
@@ -45,10 +45,21 @@
['value' => true, 'label' => 'Consistent name (no rolling updates)'],
]" :disabled="! $canUpdate" />
@if ($isConsistentContainerNameEnabled === true)
-
+
+ @else
+
@endif
diff --git a/resources/views/livewire/project/application/analytics-placeholder.blade.php b/resources/views/livewire/project/application/analytics-placeholder.blade.php
new file mode 100644
index 0000000000..1cb6cffc70
--- /dev/null
+++ b/resources/views/livewire/project/application/analytics-placeholder.blade.php
@@ -0,0 +1,42 @@
+
+ {{-- Real section chrome around skeleton bodies, so the tab has no layout jump on load. --}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/livewire/project/application/analytics.blade.php b/resources/views/livewire/project/application/analytics.blade.php
new file mode 100644
index 0000000000..5a0fa32a87
--- /dev/null
+++ b/resources/views/livewire/project/application/analytics.blade.php
@@ -0,0 +1,221 @@
+ 'Referrers',
+ 'browser' => 'Browsers',
+ 'os' => 'Operating systems',
+];
+$analyticsServerUuid = $application->destination?->server?->uuid;
+?>
+
+ @if (! $enabled)
+
+ @if ($analyticsServerUuid)
+
+
+ Server analytics
+
+
+
+ @endif
+
+
+ @elseif (! $overview)
+
+
+
+ @else
+ @if ($this->isLivePollable())
+
+ @endif
+
+
+
+
+ @include('livewire.traffic._live-toggle')
+
+
+ 24 hours
+
+
+ 7 days
+
+
+ 30 days
+
+
+
+
+
+
+
+
Requests
+
{{ number_format($overview['requests'] ?? 0) }}
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-requests',
+ 'initial' => $this->requestsSpark(),
+ 'colorVar' => '--chart-status-3xx',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'requestsSpark',
+ ])
+
+
+
+
Unique visitors
+
{{ number_format($overview['uniqueVisitors'] ?? 0) }}
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-visitors',
+ 'initial' => $this->uniquesSpark(),
+ 'colorVar' => '--chart-status-2xx',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'uniquesSpark',
+ ])
+
+
+
+
Bandwidth
+
{{ formatBytes($this->bandwidthBytes()) }}
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-bandwidth',
+ 'initial' => $this->bandwidthSpark(),
+ 'colorVar' => '--chart-spark-bandwidth',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'bandwidthSpark',
+ ])
+
+
+
+
Error rate
+
{{ $this->errorRate() }}%
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-errors',
+ 'initial' => $this->errorsSpark(),
+ 'colorVar' => '--chart-status-5xx',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'errorsSpark',
+ ])
+
+
+
+
p95 latency
+
{{ number_format($overview['latencyP95'] ?? 0, 1) }} ms
+
+ @include('livewire.traffic._sparkline', [
+ 'id' => $chartId.'-spark-latency',
+ 'initial' => $this->latencySpark(),
+ 'colorVar' => '--chart-status-4xx',
+ 'event' => 'refreshChartData-'.$chartId.'-status',
+ 'key' => 'latencySpark',
+ ])
+
+
+
+
+
+
+ @include('livewire.traffic._requests-chart')
+
+
+
+ @include('livewire.traffic._status-codes')
+
+
+
+ @include('livewire.traffic._paths-list', ['paths' => $topPaths])
+
+
+
+ @include('livewire.traffic._geo', [
+ 'countries' => data_get($breakdowns, 'country', []),
+ 'attribution' => $attribution,
+ ])
+
+
+ {{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
+
+
+ @php $deviceChart = $this->deviceChartData(); @endphp
+ @include('livewire.traffic._device-chart', [
+ 'labels' => $deviceChart['labels'],
+ 'series' => $deviceChart['series'],
+ ])
+
+
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'protocol',
+ 'label' => 'Top HTTP versions',
+ 'rows' => data_get($breakdowns, 'protocol', []),
+ 'helper' => 'Request volume by negotiated HTTP protocol version.',
+ ])
+
+
+
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'cache',
+ 'label' => 'Top cache statuses',
+ 'rows' => data_get($breakdowns, 'cache', []),
+ 'helper' => 'Reverse-proxy cache outcome (hit, miss, bypass, β¦) by request count.',
+ ])
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'status',
+ 'label' => 'Top status codes',
+ 'rows' => data_get($breakdowns, 'status', []),
+ 'helper' => 'Most frequent HTTP response status codes for the selected range.',
+ ])
+
+
+ {{-- Referrers / browsers / OS / AI agents / IPs, two per row. --}}
+
+ @foreach ($dimensionLabels as $dimension => $label)
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => $dimension,
+ 'label' => $label,
+ 'rows' => data_get($breakdowns, $dimension, []),
+ ])
+ @endforeach
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'agent',
+ 'label' => 'AI agents & bots',
+ 'rows' => data_get($breakdowns, 'agent', []),
+ 'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, β¦) by request count.',
+ ])
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'ip',
+ 'label' => 'Top IPs',
+ 'rows' => data_get($breakdowns, 'ip', []),
+ 'helper' => 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).',
+ ])
+
+
+ {{-- User agents (full width β raw UA strings are long). --}}
+ @include('livewire.traffic._breakdown-section', [
+ 'dimension' => 'useragent',
+ 'label' => 'Top user agents',
+ 'rows' => data_get($breakdowns, 'useragent', []),
+ 'helper' => 'Most frequent raw User-Agent strings for the selected range.',
+ ])
+ @endif
+
diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php
index 41127287c4..11d6400bf6 100644
--- a/resources/views/livewire/project/application/configuration.blade.php
+++ b/resources/views/livewire/project/application/configuration.blade.php
@@ -20,6 +20,7 @@
@elseif ($currentRoute === 'project.application.environment-variables')
+
@elseif ($currentRoute === 'project.application.persistent-storage')
@elseif ($currentRoute === 'project.application.source' && $application->git_based())
@@ -44,6 +45,9 @@
@elseif ($currentRoute === 'project.application.metrics')
+ @elseif ($currentRoute === 'project.application.analytics')
+
@elseif ($currentRoute === 'project.application.tags')
@elseif ($currentRoute === 'project.application.danger')
diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php
index dd5e344fee..617fabb073 100644
--- a/resources/views/livewire/project/application/domains.blade.php
+++ b/resources/views/livewire/project/application/domains.blade.php
@@ -425,4 +425,5 @@
@endif
+ @include('livewire.project.shared.dns-provider-management')
diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php
index ccfa948f4b..e2af1e96aa 100644
--- a/resources/views/livewire/project/application/heading.blade.php
+++ b/resources/views/livewire/project/application/heading.blade.php
@@ -43,110 +43,69 @@
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
@can('deploy', $application)
-
-
-
- Actions
-
-
-
-
-
-
-
- @if (!str($application->status)->startsWith('exited'))
+
+ @if (str($application->status)->startsWith('exited'))
+
+
+ {{ $application->stoppedAfterRestartLimit() ? 'Retry deployment' : 'Deploy' }}
+
@if (!$application->destination->server->isSwarm())
- @can('deploy', $application)
-
-
- Deploy
-
- @else
-
-
- Deploy
-
- @endcan
- @endif
- @if ($application->build_pack !== 'dockercompose')
- @if ($application->destination->server->isSwarm())
- @can('deploy', $application)
-
-
- Update Service
-
- @else
-
-
- Update Service
-
- @endcan
- @else
- @can('deploy', $application)
-
-
- Restart
-
- @else
-
-
- Restart
-
- @endcan
- @endif
- @endif
- @else
- @can('deploy', $application)
-
-
- {{ $application->stoppedAfterRestartLimit() ? 'Retry deployment' : 'Deploy' }}
-
- @else
-
-
- {{ $application->stoppedAfterRestartLimit() ? 'Retry deployment' : 'Deploy' }}
-
- @endcan
- @endif
- @if (!$application->destination->server->isSwarm())
- @can('deploy', $application)
-
{{ $application->stoppedAfterRestartLimit() ? 'Retry deployment (without cache)' : 'Deploy (without cache)' }}
- @else
-
-
- {{ $application->stoppedAfterRestartLimit() ? 'Retry deployment (without cache)' : 'Deploy (without cache)' }}
+ @endif
+ @if ($application->container_present !== false)
+
+
+ Remove container
- @endcan
+ @endif
+ @else
+ @if ($application->destination->server->isSwarm())
+ @if ($application->build_pack !== 'dockercompose')
+
+
+ Update Service
+
+ @else
+
+
+ Stop
+
+ @endif
+ @else
+
+
+ Redeploy
+
+
+
+ {{ str($application->status)->startsWith('running') ? 'Redeploy (without cache)' : 'Deploy (without cache)' }}
+
+ @if ($application->build_pack !== 'dockercompose')
+
+
+ Restart
+
+ @endif
+ @endif
+ @unless ($application->destination->server->isSwarm() && $application->build_pack === 'dockercompose')
+
+
+ Stop
+
+ @endunless
@endif
- @if (!str($application->status)->startsWith('exited') || $application->container_present !== false)
-
-
- {{ str($application->status)->startsWith('exited') ? 'Remove container' : 'Stop' }}
-
- @endif
-
-
+
@endcan
@endif
@@ -188,108 +147,69 @@
@can('deploy', $application)
-
-
- Actions
-
-
-
-
+
@if (str($application->status)->startsWith('exited'))
- user()->can('deploy', $application))
- wire:click="deploy" @click="open = false" role="menuitem">
-
+
+
{{ $application->stoppedAfterRestartLimit() ? 'Retry deployment' : 'Deploy' }}
-
+
@if (!$application->destination->server->isSwarm())
- user()->can('deploy', $application))
- wire:click="deploy(true)" @click="open = false" role="menuitem">
+
{{ $application->stoppedAfterRestartLimit() ? 'Retry deployment (without cache)' : 'Deploy (without cache)' }}
@endif
@if ($application->container_present !== false)
+ @click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()" role="menuitem">
Remove container
@endif
@else
- @if (!$application->destination->server->isSwarm())
- @can('deploy', $application)
-
-
- Redeploy
-
+ @if ($application->destination->server->isSwarm())
+ @if ($application->build_pack !== 'dockercompose')
+
+
+ Update Service
+
@else
-
-
- Redeploy
-
- @endcan
+
+
+ Stop
+
+ @endif
+ @else
+
+
+ Redeploy
+
user()->can('deploy', $application))
wire:click="{{ str($application->status)->startsWith('running') ? 'force_deploy_without_cache' : 'deploy(true)' }}"
@click="open = false" role="menuitem">
{{ str($application->status)->startsWith('running') ? 'Redeploy (without cache)' : 'Deploy (without cache)' }}
- @endif
- @if ($application->build_pack !== 'dockercompose')
- @if ($application->destination->server->isSwarm())
- @can('deploy', $application)
-
-
- Update Service
-
- @else
-
-
- Update Service
-
- @endcan
- @else
- @can('deploy', $application)
-
-
- Restart
-
- @else
-
-
- Restart
-
- @endcan
+ @if ($application->build_pack !== 'dockercompose')
+
+
+ Restart
+
@endif
@endif
- @can('deploy', $application)
+ @unless ($application->destination->server->isSwarm() && $application->build_pack === 'dockercompose')
Stop
- @else
-
-
- Stop
-
- @endcan
+ @endunless
@endif
-
-
+
@endcan
@endif
diff --git a/resources/views/livewire/project/application/internal-access.blade.php b/resources/views/livewire/project/application/internal-access.blade.php
index 8ab1442ba5..6997b766b8 100644
--- a/resources/views/livewire/project/application/internal-access.blade.php
+++ b/resources/views/livewire/project/application/internal-access.blade.php
@@ -15,7 +15,7 @@
Internal access
@if ($currentInternalHostname)
-
+
@else
Internal hostname
@@ -25,9 +25,9 @@
readonly aria-live="polite">
@endif
-
-
-
+
+
+
diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php
index ce973200fb..018beefebd 100644
--- a/resources/views/livewire/project/application/partials/domain-row.blade.php
+++ b/resources/views/livewire/project/application/partials/domain-row.blade.php
@@ -159,10 +159,12 @@
+ ]" :checkboxes="[['id' => 'deleteManagedDns', 'label' => 'Also delete the DNS record created by Coolify, if present.']]"
+ :confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Remove domain">
diff --git a/resources/views/livewire/project/application/traffic-overview.blade.php b/resources/views/livewire/project/application/traffic-overview.blade.php
new file mode 100644
index 0000000000..831a372ec2
--- /dev/null
+++ b/resources/views/livewire/project/application/traffic-overview.blade.php
@@ -0,0 +1,66 @@
+@php
+ $analyticsRoute = route('project.application.analytics', [
+ 'project_uuid' => $application->environment->project->uuid,
+ 'environment_uuid' => $application->environment->uuid,
+ 'application_uuid' => $application->uuid,
+ ]);
+@endphp
+
+
+ @if (! $enabled)
+ @if ($eligible && $serverUuid)
+
+
+
+
Traffic analytics
+
+ Enable Sentinel traffic analytics on this server to see requests, visitors, and
+ geography for this application. Restarts the proxy + Sentinel.
+
+
+
+ Server settings
+
+
+
+
+ @endif
+ @else
+
+
+
+ @if (! $this->hasData())
+
+ No traffic recorded in the last 24h yet.
+
+ @else
+
+
+ Requests
+ {{ number_format($overview['requests'] ?? 0) }}
+
+
+ Unique visitors
+ {{ number_format($overview['uniqueVisitors'] ?? 0) }}
+
+
+ Error rate
+ {{ $this->errorRate() }}%
+
+
+ p95 latency
+ {{ number_format($overview['latencyP95'] ?? 0, 1) }} ms
+
+
+ @endif
+
+ @endif
+
diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php
index fb32c18ba4..5f7f4c5797 100644
--- a/resources/views/livewire/project/database/configuration.blade.php
+++ b/resources/views/livewire/project/database/configuration.blade.php
@@ -31,6 +31,7 @@
@endif
@elseif ($currentRoute === 'project.database.environment-variables')
+
@elseif ($currentRoute === 'project.database.servers')
@elseif ($currentRoute === 'project.database.persistent-storage')
diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php
index ad1c43889a..2d28510165 100644
--- a/resources/views/livewire/project/database/heading.blade.php
+++ b/resources/views/livewire/project/database/heading.blade.php
@@ -75,64 +75,24 @@
@if ($database->destination->server->isFunctional())
@can('manage', $database)
-
-
-
- Actions
-
-
-
-
-
-
-
+
@if (! $databaseStatus->startsWith('exited'))
- @can('manage', $database)
-
-
- Restart
-
-
-
- Stop
-
- @else
-
-
- Restart
-
-
-
- Stop
-
- @endcan
+
+
+ Restart
+
+
+
+ Stop
+
@else
- @can('manage', $database)
-
-
- Start
-
- @else
-
-
- Start
-
- @endcan
+
+
+ Start
+
@endif
-
-
+
@endcan
@endif
@@ -145,25 +105,24 @@
@if ($database->destination->server->isFunctional())
@can('manage', $database)
-
- @if (! $databaseStatus->startsWith('exited'))
- user()->can('manage', $database))
- @click="document.getElementById('database-restart-trigger')?.click()">
- Restart
-
- user()->can('manage', $database))
- @click="document.getElementById('database-stop-trigger')?.click()">
- Stop
-
- @else
-
- Start
-
- @endif
-
+
+ @if (! $databaseStatus->startsWith('exited'))
+
+
+ Restart
+
+
+
+ Stop
+
+ @else
+
+
+ Start
+
+ @endif
+
@endcan
@else
diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php
index 6bb5892dca..9d70a41780 100644
--- a/resources/views/livewire/project/database/import-form.blade.php
+++ b/resources/views/livewire/project/database/import-form.blade.php
@@ -48,8 +48,8 @@
@endscript
-
- Restoring a backup is destructive. Review the source and import command before continuing.
+
+ Review the source and import command before continuing. Existing objects can cause the import to fail unless replacement is enabled.
@else
-
@endif
@@ -95,6 +95,13 @@
['value' => false, 'label' => 'Backup contains one database'],
]" />
+ @if (in_array($resourceDbType, ['standalone-postgresql', 'postgresql'], true) && ! $dumpAll)
+
+
+
+ @endif
@@ -185,7 +192,7 @@
Copy backup file to database container
Execute restore command
-
All existing data will be replaced.
+
Existing objects can cause the import to fail unless replacement is enabled.
@@ -245,7 +252,7 @@
Copy file into database container
Execute restore command
- All existing data will be replaced.
+ Existing objects can cause the import to fail unless replacement is enabled.
diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php
index e88c5bc2f2..a129884644 100644
--- a/resources/views/livewire/project/service/configuration.blade.php
+++ b/resources/views/livewire/project/service/configuration.blade.php
@@ -180,6 +180,7 @@
@elseif ($currentRoute === 'project.service.environment-variables')
+
@elseif ($currentRoute === 'project.service.storages')
@endif
+ @include('livewire.project.shared.dns-provider-management')
diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php
index 22ce7dab3e..8c6531f3bc 100644
--- a/resources/views/livewire/project/service/heading.blade.php
+++ b/resources/views/livewire/project/service/heading.blade.php
@@ -72,8 +72,7 @@
{{ $service->name }}
-
+
@@ -87,92 +86,45 @@
@if ($service->isDeployable)
@can('deploy', $service)
-
-
-
-
- Actions
-
-
-
-
-
-
-
+
@if ($selectedResource && $selectedResource->container_present !== false && $selectedResourceStatus->startsWith('exited'))
-
-
+
+
Remove container
-
+
@elseif ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
- @can('deploy', $service)
-
-
- Restart current version
-
- @else
-
-
- Restart current version
-
- @endcan
+
+
+ Restart
+
@if ($serviceStatus->contains('running'))
user()->can('deploy', $service))
- @click="$wire.dispatch('pullAndRestartEvent'); open = false"
- role="menuitem">
+ @click="$wire.dispatch('pullAndRestartEvent'); open = false" role="menuitem">
- Pull latest and restart
+ Restart (pull latest)
- @else
+ @endif
+ @if ($serviceStatus->contains('degraded'))
user()->can('deploy', $service))
- @click="$wire.dispatch('forceDeployEvent'); open = false"
- role="menuitem">
+ @click="$wire.dispatch('forceDeployEvent'); open = false" role="menuitem">
Force Restart
@endif
- @can('stop', $service)
-
-
- Stop
-
- @else
-
-
- Stop
-
- @endcan
- @else
- @can('deploy', $service)
-
-
- Deploy
-
- @else
-
-
- Deploy
-
- @endcan
user()->can('deploy', $service))
+ @disabled(!auth()->user()->can('stop', $service))
+ @click="open = false; document.getElementById('service-stop-trigger')?.click()" role="menuitem">
+
+ Stop
+
+ @else
+
+
+
+ Deploy
+
+
Force Deploy
@@ -184,8 +136,7 @@
Force Cleanup Containers
@endif
-
-
+
@endcan
@else
@can('deploy', $service)
@@ -225,79 +176,57 @@
@can('deploy', $service)
-
-
- Actions
-
-
-
- @if ($selectedResource && $selectedResource->container_present !== false && $selectedResourceStatus->startsWith('exited'))
+
+ @if ($selectedResource && $selectedResource->container_present !== false && $selectedResourceStatus->startsWith('exited'))
+
+
+ Remove container
+
+ @elseif ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
+
+
+ Restart
+
+ @if ($serviceStatus->contains('running'))
-
- Remove container
+ @click="$wire.dispatch('pullAndRestartEvent'); open = false" role="menuitem">
+
+ Restart (pull latest)
- @else
- @if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
- user()->can('deploy', $service))
- @click="open = false; document.getElementById('service-restart-trigger')?.click()">
-
- Restart
-
- @if ($serviceStatus->contains('running'))
- user()->can('deploy', $service))
- @click="$wire.dispatch('pullAndRestartEvent'); open = false">
-
- Restart (pull latest)
-
- @endif
- user()->can('stop', $service))
- @click="open = false; document.getElementById('service-stop-trigger')?.click()">
-
- Stop
-
- @elseif (! $serviceStatus->contains('running'))
- user()->can('deploy', $service))
- @click="deploying = true; $wire.dispatch('startEvent'); open = false">
-
- Deploy
-
- @endif
- @if (! $serviceStatus->contains('running'))
-
@endif
@if ($serviceStatus->contains('degraded'))
user()->can('deploy', $service))
- @click="$wire.dispatch('forceDeployEvent'); open = false">
+ @click="$wire.dispatch('forceDeployEvent'); open = false" role="menuitem">
Force Restart
- @elseif (! $serviceStatus->contains('running'))
- user()->can('deploy', $service))
- @click="$wire.dispatch('forceDeployEvent'); open = false">
-
- Force Deploy
-
- user()->can('stop', $service))
- @click="$wire.dispatch('cleanupEvent'); open = false">
-
- Force Cleanup Containers
-
@endif
- @endif
-
-
+
user()->can('stop', $service))
+ @click="open = false; document.getElementById('service-stop-trigger')?.click()" role="menuitem">
+
+ Stop
+
+ @else
+
+
+
+ Deploy
+
+
+
+ Force Deploy
+
+
user()->can('stop', $service))
+ @click="$wire.dispatch('cleanupEvent'); open = false" role="menuitem">
+
+ Force Cleanup Containers
+
+ @endif
+
@endcan
@else
@can('deploy', $service)
diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php
index 97127904e4..7038756440 100644
--- a/resources/views/livewire/project/service/partials/domain-table.blade.php
+++ b/resources/views/livewire/project/service/partials/domain-table.blade.php
@@ -194,10 +194,12 @@
Mount a Docker volume inside the container.
- @if ($isSwarm)
- Swarm Mode detected: You need to set a shared
- volume
- (EFS/NFS/etc) on all the worker nodes if you would like to use a
- persistent
- volumes.
- @endif
- @if ($isSwarm)
-
- @else
-
- @endif
diff --git a/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php b/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php
index 43ea254390..46ab71e14f 100644
--- a/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php
+++ b/resources/views/livewire/project/shared/cloudflare-autoconfigure.blade.php
@@ -1,6 +1,7 @@
{{-- DNS entries: Domain Connect (Cloud only + key) and/or generic Type/Name/Value records. --}}
@php
$domainConnectAvailable = $this->domainConnectAvailable();
+ $dnsAuthResource = property_exists($this, 'application') ? $this->application : $this->service;
@endphp
@@ -8,10 +9,12 @@
x-bind:aria-expanded="dnsEntriesOpen" title="DNS entries for this server">
DNS entries
-
-
-
+
+
+
+
+
@@ -23,7 +26,7 @@
@endif
+ wire:click="openManualDnsRecords" @click="dnsEntriesOpen = false">
Manual records
@@ -176,6 +179,7 @@
Type
Name
Value
+
Action
@@ -196,6 +200,17 @@
'break' => true,
])
+
+ @php($recordProviders = collect($dnsProviderProposals)->where('hostname', $record['name'])->where('managed', false))
+ @foreach ($recordProviders as $provider)
+
+ Add with {{ $provider['credential'] }}
+
+ @endforeach
+
@endforeach
diff --git a/resources/views/livewire/project/shared/dns-provider-management.blade.php b/resources/views/livewire/project/shared/dns-provider-management.blade.php
new file mode 100644
index 0000000000..b33d073e13
--- /dev/null
+++ b/resources/views/livewire/project/shared/dns-provider-management.blade.php
@@ -0,0 +1,51 @@
+
+ @php
+ $dnsAuthResource = property_exists($this, 'application') ? $this->application : $this->service;
+ @endphp
+ @if ($showDnsProviderModal)
+
+ @endif
+
diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php
index 0b13cccee4..1d10980bd5 100644
--- a/resources/views/livewire/project/shared/environment-variable/add.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php
@@ -14,7 +14,9 @@
diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php
index 923514efcc..ae95ef6806 100644
--- a/resources/views/livewire/project/shared/environment-variable/all.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php
@@ -168,7 +168,7 @@
Add
-
+
@endcan
@@ -219,7 +219,8 @@
@else
+ :isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType"
+ :resourceableType="get_class($resource)" :resourceableId="$resource->id" />
@endif
@endforeach
diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php
index 84d03c0fe8..5492d33f90 100644
--- a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php
@@ -28,7 +28,10 @@
-
-
-
-
+
+ @unless (auth()->user()?->isMember() ?? true)
+
+ @endunless
+
+ @if (! $isLocked && ! $isValueHidden)
+
+ @endif
{{-- Open modal immediately (Alpine); decrypt value in a follow-up Livewire request. --}}
@else
diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php
index b5e9c21039..f591932e29 100644
--- a/resources/views/livewire/project/shared/get-logs.blade.php
+++ b/resources/views/livewire/project/shared/get-logs.blade.php
@@ -14,6 +14,7 @@
logFilters: JSON.parse(localStorage.getItem('coolify-log-filters')) || {error: true, warning: true, debug: true, info: true},
searchQuery: '',
matchCount: 0,
+ expandedLogs: {},
containerName: '{{ $container ?? "logs" }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
@@ -119,6 +120,22 @@
if (/\b(debug|dbg|trace|verbose)\b/.test(content)) return 'debug';
return 'info';
},
+ toggleLogDetails(key, event) {
+ if (window.getSelection()?.toString()) return;
+ if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) return;
+ if (event.type === 'keydown') event.preventDefault();
+ this.expandedLogs[key] = !this.expandedLogs[key];
+ },
+ isLogExpanded(key) {
+ return this.expandedLogs[key] === true;
+ },
+ formatLogDetails(content) {
+ try {
+ return JSON.stringify(JSON.parse(content), null, 2);
+ } catch {
+ return content;
+ }
+ },
toggleLogFilter(level) {
this.logFilters[level] = !this.logFilters[level];
localStorage.setItem('coolify-log-filters', JSON.stringify(this.logFilters));
@@ -515,7 +532,17 @@
$displayLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== '');
$lineOccurrences = [];
@endphp
-
+
!$showTimeStamps,
+ ])>
+
+ @if ($showTimeStamps)
+ Time
+ @endif
+ Type
+ Message
+
No matches found.
@@ -543,17 +570,38 @@
$timestamp = $carbonTs->format('Y-M-d H:i:s');
}
@endphp
-
+ @php($lineKey = $lineFingerprint.'-'.$lineOccurrence)
+
@if ($timestamp && $showTimeStamps)
{{ $timestamp }}
@endif
{{ $logContent }}
+
@endforeach
@else
-
No logs yet.
+
+
+ Loading logs
+
+
+
+
+
+
+
No logs yet
+
Logs will appear here when the container produces output.
+
+
@endif
diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php
index 8b0ca2b75c..17f89523e2 100644
--- a/resources/views/livewire/project/shared/metrics.blade.php
+++ b/resources/views/livewire/project/shared/metrics.blade.php
@@ -24,9 +24,9 @@
-
- Enable metrics for this server before collecting application usage data.
-
+
@elseif (!str($resource->status)->contains('running'))
+
$break])>{{ $text }}
-
-
-
-
-
-
-
-
+
diff --git a/resources/views/livewire/project/shared/resource-details.blade.php b/resources/views/livewire/project/shared/resource-details.blade.php
index 2e92c73146..1a032f6964 100644
--- a/resources/views/livewire/project/shared/resource-details.blade.php
+++ b/resources/views/livewire/project/shared/resource-details.blade.php
@@ -3,8 +3,8 @@
@@ -12,8 +12,8 @@
@endif
@@ -22,8 +22,8 @@
@endif
@@ -32,8 +32,8 @@
@endif
@@ -43,10 +43,10 @@
Stack Sub-Resources
@foreach ($stack_applications as $item)
-
+
@endforeach
@foreach ($stack_databases as $item)
-
+
@endforeach
diff --git a/resources/views/livewire/project/shared/secret-manager-links.blade.php b/resources/views/livewire/project/shared/secret-manager-links.blade.php
new file mode 100644
index 0000000000..3fa283c9f8
--- /dev/null
+++ b/resources/views/livewire/project/shared/secret-manager-links.blade.php
@@ -0,0 +1,125 @@
+
+ @php
+ $secretManagerDescription = 'Reference remote secrets in your environment variables with {{vault.KEY}}. Values are fetched at deployment time and are never stored in the Coolify database. Changing the source does not re-check existing references β missing keys fail the next deployment.';
+ $removeSourceWarning = 'Existing {{vault.*}} reference variables will fail the next deployment until they are removed too.';
+ @endphp
+
+
+ @if (! $link && $availableTokens->isEmpty())
+
+ @else
+ @can('update', $resource)
+
+ @else
+ @if ($link)
+
+ {{ $link->integrationToken->providerName() }}
+
+ {{ $link->integrationToken->name }} · {{ $link->sourceSummary() }}
+
+
+ @endif
+ @endcan
+ @endif
+
+
diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php
index 98521c8204..a6d207d60c 100644
--- a/resources/views/livewire/project/shared/storages/all.blade.php
+++ b/resources/views/livewire/project/shared/storages/all.blade.php
@@ -163,7 +163,24 @@
Source Path
-
+ @if (filled($form['hostPath']))
+
+ @else
+
-
+ @endif
diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php
deleted file mode 100644
index 3662568087..0000000000
--- a/resources/views/livewire/project/shared/storages/show.blade.php
+++ /dev/null
@@ -1,156 +0,0 @@
-@php
- $showActionsColumn = $resource instanceof \App\Models\Application;
- $gridClass = match (true) {
- $supportsPreviewSuffix => 'volumes-table-grid-with-pr',
- $showActionsColumn => 'volumes-table-grid',
- default => 'volumes-table-grid-readonly',
- };
- $canUpdate = auth()->user()?->can('update', $resource) ?? false;
- $inputsReadonly = $isReadOnly || ! $canUpdate;
- $displayHostPath = filled($hostPath) ? $hostPath : 'β';
-@endphp
-
-@if ($inputsReadonly)
- {{-- Read-only: plain data-table row (service / compose / no permission) --}}
-
-
-
-
Volume Name
-
-
{{ $name }}
- @if ($hasEnabledBackup)
- @if ($backupUrl)
-
- Backup
-
- @else
-
- Backup
-
- @endif
- @endif
-
-
-
-
- Source Path
-
- {{ $displayHostPath }}
-
-
-
-
- Destination Path
- {{ $mountPath }}
-
-
- @if ($supportsPreviewSuffix)
-
- PR suffix
- {{ $isPreviewSuffixEnabled ? 'Add suffix' : 'Share volume' }}
-
- @endif
-
- @if ($showActionsColumn)
-
- @if ($canUpdate)
- @if ($showBackupModal)
-
-
-
- @else
-
- Backup
-
- @endif
- @else
- β
- @endif
-
- @endif
-
-
-@else
- {{-- Editable volume row --}}
-
-@endif
diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php
index 9e7b494128..5c360cc5ca 100644
--- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php
+++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php
@@ -71,7 +71,7 @@
-
+
diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php
index c8c42763fa..6c87e3098d 100644
--- a/resources/views/livewire/project/shared/webhooks.blade.php
+++ b/resources/views/livewire/project/shared/webhooks.blade.php
@@ -39,7 +39,7 @@
-
+
@if ($githubManualWebhook && $gitlabManualWebhook)
@@ -70,7 +70,7 @@
-
+
@can('update', $resource)
-
+
@endif
diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php
index acc36f5269..ddffd51094 100644
--- a/resources/views/livewire/security/api-tokens.blade.php
+++ b/resources/views/livewire/security/api-tokens.blade.php
@@ -112,7 +112,12 @@
This value will not be shown again after you leave this page.
-
+
+
+
+
@endif
diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php
new file mode 100644
index 0000000000..e99106418f
--- /dev/null
+++ b/resources/views/livewire/security/integration-token-editor.blade.php
@@ -0,0 +1,115 @@
+
diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php
new file mode 100644
index 0000000000..fce54f1929
--- /dev/null
+++ b/resources/views/livewire/security/integration-token-form.blade.php
@@ -0,0 +1,109 @@
+
diff --git a/resources/views/livewire/security/integration-tokens.blade.php b/resources/views/livewire/security/integration-tokens.blade.php
new file mode 100644
index 0000000000..33ac01e5fb
--- /dev/null
+++ b/resources/views/livewire/security/integration-tokens.blade.php
@@ -0,0 +1,94 @@
+
+
+ Integration Tokens | Coolify
+
+
+
+
+
+
diff --git a/resources/views/livewire/server/analytics/show.blade.php b/resources/views/livewire/server/analytics/show.blade.php
new file mode 100644
index 0000000000..123c4dd68f
--- /dev/null
+++ b/resources/views/livewire/server/analytics/show.blade.php
@@ -0,0 +1,22 @@
+
+
+ {{ data_get_str($server, 'name')->limit(10) }} > Analytics | Coolify
+
+
+
+
+
+
+
+
+ @can('update', $server)
+
+ @endcan
+
+
+
+
+
diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php
index 94d2050dc2..2279e62e39 100644
--- a/resources/views/livewire/server/ca-certificate/show.blade.php
+++ b/resources/views/livewire/server/ca-certificate/show.blade.php
@@ -34,7 +34,7 @@
diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php
index bb99c9980c..442fc1979f 100644
--- a/resources/views/livewire/server/charts.blade.php
+++ b/resources/views/livewire/server/charts.blade.php
@@ -14,33 +14,50 @@
@if ($poll) wire:poll.5000ms="pollData" @endif
@endif>
@if ($server->isMetricsEnabled())
-
-
-
-
-
- Disable metrics
-
-
-
+
+
+
+
+
+
+ Disable metrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Five and ten minute ranges refresh automatically every five seconds.
+
+
+
@@ -64,14 +81,15 @@
return `${Number(number.toFixed(precision))}%`;
};
- const formatTimestamp = timestamp => {
- const date = new Date(timestamp);
-
- return `${date.toLocaleString(undefined, {
- timeZone: 'UTC',
- hour12: false
- })} UTC`;
- };
+ const formatLocalTimestamp = timestamp => new Date(timestamp).toLocaleString(undefined, {
+ hour12: false,
+ timeZoneName: 'short',
+ });
+ const formatUtcTimestamp = timestamp => new Date(timestamp).toLocaleString(undefined, {
+ hour12: false,
+ timeZone: 'UTC',
+ timeZoneName: 'short',
+ });
const chartOptions = (name, color, loadingText) => ({
chart: {
@@ -118,7 +136,7 @@
xaxis: {
type: 'datetime',
labels: {
- datetimeUTC: true,
+ datetimeUTC: false,
style: {
colors: textColor,
},
@@ -163,7 +181,8 @@
return ``;
},
},
@@ -181,18 +200,20 @@
cpuChart.render();
memoryChart.render();
- Livewire.on('refreshChartData-{!! $chartId !!}-cpu', chartData => {
+ Livewire.on('refreshChartData-{!! $chartId !!}-metrics', chartData => {
checkTheme();
+ const data = Array.isArray(chartData) ? chartData[0] : chartData;
+
cpuChart.updateOptions({
colors: [cpuColor],
series: [{
name: 'CPU',
- data: chartData[0].seriesData,
+ data: data.cpuSeries,
}],
xaxis: {
type: 'datetime',
labels: {
- datetimeUTC: true,
+ datetimeUTC: false,
style: {
colors: textColor,
},
@@ -217,20 +238,16 @@
},
},
});
- });
-
- Livewire.on('refreshChartData-{!! $chartId !!}-memory', chartData => {
- checkTheme();
memoryChart.updateOptions({
colors: [ramColor],
series: [{
name: 'Memory',
- data: chartData[0].seriesData,
+ data: data.memorySeries,
}],
xaxis: {
type: 'datetime',
labels: {
- datetimeUTC: true,
+ datetimeUTC: false,
style: {
colors: textColor,
},
@@ -290,6 +307,7 @@
@endif
+
diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php
index e75f77b5b4..ac25ca2c1d 100644
--- a/resources/views/livewire/server/navbar.blade.php
+++ b/resources/views/livewire/server/navbar.blade.php
@@ -156,8 +156,17 @@
class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
{{ $server->name }}
-
+
@@ -165,66 +174,31 @@
@if ($server->proxySet())
@can('manageProxy', $server)
-
-
-
- Actions
-
-
-
-
-
-
-
- @if ($proxyCanBeStopped)
-
-
-
-
- Restart Proxy
-
-
-
-
-
- Stop Proxy
-
- @if ($traefikDashboardAvailable)
-
-
-
-
- Traefik Dashboard
-
- @endif
- @else
-
-
-
-
- Start Proxy
-
- @endif
+
+ @if ($proxyCanBeStopped)
+
+
+ Restart Proxy
+
-
-
-
- Refresh Proxy Status
+ @click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
+ role="menuitem">
+
+ Stop Proxy
-
-
+ @else
+
+
+ Start Proxy
+
+ @endif
+
+
+ Refresh Proxy Status
+
+
{{-- Programmatic open only (clicked from the Actions menu). Keep fully
display:none so the modal shells never reserve a layout row. --}}
@@ -299,66 +273,40 @@
@if ($server->proxySet())
@can('manageProxy', $server)
-
-
- Actions
-
-
-
-
- @if ($proxyCanBeStopped)
-
-
-
-
- Restart Proxy
-
-
-
-
-
- Stop Proxy
-
- @else
-
-
-
-
- Start Proxy
-
- @endif
-
-
-
-
-
- Refresh Proxy Status
-
- @if ($traefikDashboardAvailable)
-
-
-
-
- Traefik Dashboard
-
- @endif
+ @if ($traefikDashboardAvailable)
+
-
+ @endif
+
+ @if ($proxyCanBeStopped)
+
+
+ Restart Proxy
+
+
+
+ Stop Proxy
+
+ @else
+
+
+ Start Proxy
+
+ @endif
+
+
+ Refresh Proxy Status
+
+
@endcan
@endif
diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php
index e561f412fe..1d4033ea1a 100644
--- a/resources/views/livewire/server/proxy.blade.php
+++ b/resources/views/livewire/server/proxy.blade.php
@@ -90,6 +90,7 @@
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value || $server->proxyType() === 'CADDY')
@can('update', $server)
@@ -128,6 +129,11 @@
@endif
@endif
+
+
+
+
@if ($proxySettings)
diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php
index d490b6f1db..f1e4fc3f7a 100644
--- a/resources/views/livewire/server/security/patches.blade.php
+++ b/resources/views/livewire/server/security/patches.blade.php
@@ -35,8 +35,8 @@
- Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications
- can be managed from
+ Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status
+ notifications can be managed from
notification settings .
diff --git a/resources/views/livewire/server/sentinel.blade.php b/resources/views/livewire/server/sentinel.blade.php
index a72519a474..512b0588a9 100644
--- a/resources/views/livewire/server/sentinel.blade.php
+++ b/resources/views/livewire/server/sentinel.blade.php
@@ -5,7 +5,7 @@
`$wire.set('sentinelCustomDockerImage', β¦)` (and similar) briefly
flashes this bar on every page open. --}}
+ targets="sentinelCustomUrl,sentinelToken" />
@@ -60,21 +60,6 @@
-
-
-
-
-
-
-
-
@if (isDev())
-
+
+
+
+
+
+ Apply and restart
+
+
diff --git a/resources/views/livewire/server/traffic-analytics-settings.blade.php b/resources/views/livewire/server/traffic-analytics-settings.blade.php
new file mode 100644
index 0000000000..8031654046
--- /dev/null
+++ b/resources/views/livewire/server/traffic-analytics-settings.blade.php
@@ -0,0 +1,81 @@
+
diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php
index 97822b9251..822c035b31 100644
--- a/resources/views/livewire/settings-oauth.blade.php
+++ b/resources/views/livewire/settings-oauth.blade.php
@@ -5,76 +5,126 @@
-
-
- @foreach ($oauth_settings_map as $oauth_setting)
- @php
- $provider = $oauth_setting['provider'];
- $providerLabel = str($provider)->headline();
- @endphp
-
- @endforeach
-
-
+
+
+ @foreach ($oauth_settings_map as $provider => $oauth_setting)
+
+ @endforeach
+
+
+
max('requests'));
+@endphp
+
+ @if (collect($rows)->isEmpty())
+
+ @else
+
+ @foreach ($rows as $row)
+ @php
+ $value = (string) ($row['value'] ?? '');
+ $isOther = $value === '__other__';
+ $host = ! $isOther && $dimension === 'referer' ? refererHost($value) : null;
+ $display = $isOther
+ ? 'Other'
+ : match ($dimension) {
+ 'device' => deviceLabel($value),
+ 'referer' => $host ?? 'Direct / none',
+ default => $value !== '' ? $value : 'Unknown',
+ };
+ $requests = (int) ($row['requests'] ?? 0);
+ $width = min(100, round(($requests / $maxRequests) * 100, 1));
+ @endphp
+
+ @if ($dimension === 'referer' && $host)
+
+
{{ $display }}
+ @else
+
{{ $display }}
+ @endif
+
+
{{ compactNumber($requests) }}
+
{{ formatBytes((int) ($row['bytesOut'] ?? 0)) }}
+
+ @endforeach
+ @include('livewire.traffic._pager')
+
+ @endif
+
diff --git a/resources/views/livewire/traffic/_device-chart.blade.php b/resources/views/livewire/traffic/_device-chart.blade.php
new file mode 100644
index 0000000000..049713db21
--- /dev/null
+++ b/resources/views/livewire/traffic/_device-chart.blade.php
@@ -0,0 +1,70 @@
+{{--
+ Requests-by-device donut. Renders an ApexCharts donut from the device breakdown
+ (Desktop / Mobile / Tablet / Bot β¦) and live-updates from the host component's
+ chart payload. Expects `$chartId` in scope; reads `deviceLabels`/`deviceSeries`
+ from the `refreshChartData-{chartId}-status` payload.
+
+ @param array $labels initial device labels (server-rendered first paint)
+ @param array $series initial device request counts
+--}}
+@php
+ $labels = $labels ?? [];
+ $series = $series ?? [];
+ $deviceChartId = $chartId.'-device';
+ $hasDeviceData = array_sum(array_map('intval', $series)) > 0;
+@endphp
+@if (! $hasDeviceData)
+
+@else
+
+
+ @script
+
+ @endscript
+@endif
diff --git a/resources/views/livewire/traffic/_geo.blade.php b/resources/views/livewire/traffic/_geo.blade.php
new file mode 100644
index 0000000000..f47e0506bf
--- /dev/null
+++ b/resources/views/livewire/traffic/_geo.blade.php
@@ -0,0 +1,138 @@
+{{--
+ Shared geo visualization for traffic analytics: a lightweight interactive 3D
+ dotted globe (WebGL, via cobe β see resources/js/traffic-globe.js) with a
+ request-volume marker per country, beside a scrollable ranked country list.
+ Hovering a country row rotates the globe to face it. Driven by the `country`
+ breakdown; each row is ['value' => ISO-A2, 'requests', 'bytesOut']. The globe
+ live-updates from the host's `refreshChartData-{chartId}-status` payload
+ (`geo` key), so `$chartId` must be in scope.
+
+ @param iterable $countries country-breakdown rows
+ @param ?string $attribution optional Sentinel attribution note
+--}}
+@php
+ $rows = collect($countries ?? [])
+ ->map(fn ($r) => [
+ 'value' => strtoupper((string) data_get($r, 'value', '')),
+ 'requests' => (int) data_get($r, 'requests', 0),
+ 'bytesOut' => (int) data_get($r, 'bytesOut', 0),
+ ])
+ ->filter(fn ($r) => $r['requests'] > 0);
+
+ // A row is "known" only when its code is a real, resolvable ISO-A2; everything
+ // else (absent or invalid codes) collapses into one Unknown row.
+ [$known, $unknown] = $rows->partition(
+ fn ($r) => preg_match('/^[A-Z]{2}$/', $r['value']) && countryName($r['value']) !== 'Unknown'
+ );
+
+ // Initial marker payload for the globe, keyed by ISO-A2 request volume.
+ $geoInit = $known->map(fn ($r) => ['code' => $r['value'], 'requests' => $r['requests']])->values()->all();
+
+ $countryRows = $known->values();
+ if ($unknown->isNotEmpty()) {
+ $countryRows->push([
+ 'value' => '',
+ 'requests' => $unknown->sum('requests'),
+ 'bytesOut' => $unknown->sum('bytesOut'),
+ ]);
+ }
+ $countryRows = $countryRows->sortByDesc('requests')->values();
+ $maxRequests = max(1, (int) $countryRows->max('requests'));
+
+ $hasData = $countryRows->isNotEmpty();
+ $globeId = ($chartId ?? 'traffic').'-globe';
+@endphp
+
+
+ @if (! $hasData)
+
+ @else
+
+ {{-- Interactive dotted globe (40%). wire:ignore so live-poll morphs never tear
+ down the WebGL canvas. --}}
+
+
+ {{-- Ranked, scrollable country list (60%). Hover a row to face it on the globe. --}}
+
+
+
+ @if (! empty($attribution))
+
+ {{ $attribution }}
+
+ @endif
+
+ @script
+
+ @endscript
+ @endif
+
diff --git a/resources/views/livewire/traffic/_hosts-list.blade.php b/resources/views/livewire/traffic/_hosts-list.blade.php
new file mode 100644
index 0000000000..0a1eda3a34
--- /dev/null
+++ b/resources/views/livewire/traffic/_hosts-list.blade.php
@@ -0,0 +1,45 @@
+{{--
+ Paginated "top hosts" list β request volume grouped by served hostname (an app's
+ primary domain), with a proportional bar and right-aligned compact metrics.
+ Links each host to its live origin. Expects `$hosts` in scope.
+
+ @param iterable $hosts host rows: ['host', 'requests', 'bandwidth']
+ @param ?string $keyPrefix wire:key prefix (default "analytics-host")
+--}}
+@php
+ $hosts = $hosts ?? [];
+ $keyPrefix = $keyPrefix ?? 'analytics-host';
+ $maxRequests = max(1, (int) collect($hosts)->max('requests'));
+@endphp
+@if (empty($hosts))
+
+@else
+
+ @foreach ($hosts as $row)
+ @php
+ $host = (string) ($row['host'] ?? '');
+ $known = $host !== '';
+ $requests = (int) ($row['requests'] ?? 0);
+ $width = min(100, round(($requests / $maxRequests) * 100, 1));
+ @endphp
+
+ @if ($known)
+
{{ $host }}
+ @else
+
Unknown host
+ @endif
+
+
{{ compactNumber($requests) }}
+
{{ formatBytes((int) ($row['bandwidth'] ?? 0)) }}
+
+ @endforeach
+ @include('livewire.traffic._pager')
+
+@endif
diff --git a/resources/views/livewire/traffic/_live-toggle.blade.php b/resources/views/livewire/traffic/_live-toggle.blade.php
new file mode 100644
index 0000000000..3cb76976eb
--- /dev/null
+++ b/resources/views/livewire/traffic/_live-toggle.blade.php
@@ -0,0 +1,32 @@
+{{--
+ "Live Refresh" toggle for realtime analytics. Sits next to the time-range selector.
+ Realtime is only meaningful at the 24h range (minute-level rollups), so the control
+ is hidden entirely for 7d/30d. The label is constant; the active state is shown by the
+ button color + a pulsing emerald dot. The choice is persisted per-browser in
+ localStorage. Expects `$range` in scope and a `live` bool on the host Livewire component.
+ The pulse respects prefers-reduced-motion.
+--}}
+@if ($range === '24h')
+
+
+
+
+
+
+ Live Refresh
+
+
+@endif
diff --git a/resources/views/livewire/traffic/_pager.blade.php b/resources/views/livewire/traffic/_pager.blade.php
new file mode 100644
index 0000000000..73aa17cc9b
--- /dev/null
+++ b/resources/views/livewire/traffic/_pager.blade.php
@@ -0,0 +1,23 @@
+{{--
+ Client-side "top 10" pager footer for traffic lists. Expects an enclosing
+ Alpine scope that defines reactive `page` (0-based), `per` (page size) and
+ `total` (row count). Rows themselves are toggled with x-show on their index;
+ this partial only renders the summary + prev/next controls, and hides itself
+ when everything fits on one page.
+--}}
+
+
+
+
+ Prev
+
+
+ Next
+
+
+
diff --git a/resources/views/livewire/traffic/_paths-list.blade.php b/resources/views/livewire/traffic/_paths-list.blade.php
new file mode 100644
index 0000000000..3ca1a97bdd
--- /dev/null
+++ b/resources/views/livewire/traffic/_paths-list.blade.php
@@ -0,0 +1,59 @@
+{{--
+ Paginated "top paths" list. Each row shows the request path, a proportional
+ request-volume bar, and right-aligned compact metrics (requests / bytes / p95).
+ The path links to its live URL when the owning domain is known (new tab,
+ rel="noopener noreferrer nofollow"). Expects `$paths` in scope; optional
+ `$keyPrefix` to namespace wire:keys.
+
+ @param iterable $paths path rows: ['path', 'domain'?, 'requests', 'bytesOut', 'p95']
+ @param ?string $keyPrefix wire:key prefix (default "analytics-path")
+--}}
+@php
+ $paths = $paths ?? [];
+ $keyPrefix = $keyPrefix ?? 'analytics-path';
+ $maxRequests = max(1, (int) collect($paths)->max('requests'));
+@endphp
+@if (collect($paths)->isEmpty())
+
+@else
+
+ @foreach ($paths as $path)
+ @php
+ $domain = $path['domain'] ?? null;
+ $pathStr = (string) ($path['path'] ?? '');
+ $href = $domain ? 'https://'.$domain.$pathStr : null;
+ $requests = (int) ($path['requests'] ?? 0);
+ $s4xx = (int) ($path['s4xx'] ?? 0);
+ $s5xx = (int) ($path['s5xx'] ?? 0);
+ $errorRate = $requests > 0 ? round((($s4xx + $s5xx) / $requests) * 100, 1) : 0;
+ $width = min(100, round(($requests / $maxRequests) * 100, 1));
+ @endphp
+
+ @if ($href)
+
{{ $pathStr }}
+ @else
+
{{ $pathStr }}
+ @endif
+
+
{{ compactNumber($requests) }}
+
{{ compactNumber($s4xx) }} 4xx
+
{{ compactNumber($s5xx) }} 5xx
+
{{ $errorRate }}%
+
{{ formatBytes((int) ($path['bytesOut'] ?? 0)) }}
+
{{ number_format((float) ($path['p95'] ?? 0), 1) }} ms
+
+ @endforeach
+ @include('livewire.traffic._pager')
+
+@endif
diff --git a/resources/views/livewire/traffic/_requests-chart.blade.php b/resources/views/livewire/traffic/_requests-chart.blade.php
new file mode 100644
index 0000000000..07c5870888
--- /dev/null
+++ b/resources/views/livewire/traffic/_requests-chart.blade.php
@@ -0,0 +1,146 @@
+{{--
+ Requests-over-time chart: a single area series of total requests per bucket for the
+ selected range. Reads its accent color from the shared --chart-status-3xx design
+ token. Updated via the `refreshChartData-{chartId}-status` event (the `timeSeries.requests`
+ array, aligned with `timeSeries.categories`). Expects `$chartId` in scope.
+--}}
+@php
+ $initialChartData = [
+ 'initialCategories' => array_column($series, 'bucket'),
+ 'initialRequests' => $this->requestsSpark(),
+ ];
+@endphp
+
+
+
+ {{-- No-data overlay: covers the empty chart frame when no requests fall in the range.
+ Uses the shared x-empty component so it matches the other analytics empty states
+ (e.g. Status codes). Toggled from the refresh listener below (kept mounted so the
+ chart's listener survives live/range re-renders). --}}
+
+
+
+
+
+@script
+
+@endscript
diff --git a/resources/views/livewire/traffic/_sparkline.blade.php b/resources/views/livewire/traffic/_sparkline.blade.php
new file mode 100644
index 0000000000..4fb3ce8cc2
--- /dev/null
+++ b/resources/views/livewire/traffic/_sparkline.blade.php
@@ -0,0 +1,127 @@
+{{--
+ Tiny inline sparkline for a KPI stat card. Renders an axis-less ApexCharts area
+ spark from a numeric series and (when an event name is given) live-updates from the
+ host component's chart payload so range/live refreshes stay in sync.
+
+ Initialized via Alpine (not Livewire's @script): this partial is @include'd several
+ times per page, and Livewire dedupes identical @script blocks from the same compiled
+ view β so all but the last sparkline would silently never initialize. Alpine's
+ init() runs once per element, with no such dedup.
+
+ A no-data / all-zero series draws a flat muted baseline (a constant series with
+ auto-scaling collapses to a degenerate, invisible range, so the y-axis is pinned).
+
+ @param string $id unique DOM id for this spark
+ @param array $initial initial numeric series (server-rendered first paint)
+ @param string $colorVar CSS custom property for the line color (e.g. --chart-status-3xx)
+ @param ?string $event Livewire event to listen on for updates (optional)
+ @param ?string $key payload key holding the numeric array (required with $event)
+--}}
+@php
+ $initial = $initial ?? [];
+ $colorVar = $colorVar ?? '--chart-status-3xx';
+ $event = $event ?? '';
+ $key = $key ?? '';
+ $initialCategories = array_column($series ?? [], 'bucket');
+ $label = match ($key) {
+ 'requestsSpark' => 'Requests',
+ 'uniquesSpark' => 'Visitors',
+ 'bandwidthSpark' => 'Bandwidth',
+ 'errorsSpark' => 'Errors',
+ 'latencySpark' => 'p95 latency',
+ default => 'Value',
+ };
+@endphp
+
diff --git a/resources/views/livewire/traffic/_status-codes.blade.php b/resources/views/livewire/traffic/_status-codes.blade.php
new file mode 100644
index 0000000000..0c6928b390
--- /dev/null
+++ b/resources/views/livewire/traffic/_status-codes.blade.php
@@ -0,0 +1,71 @@
+{{--
+ Status-codes summary: a single horizontal stacked bar of responses by HTTP status
+ class (2xx / 3xx / 4xx / 5xx) with a legend of per-class counts, driven by the
+ overview totals. Plain server-rendered markup β it updates via Livewire morph on
+ range/live refresh. Hovering a segment shows a cursor-following tooltip styled to
+ match the ApexCharts charts. Colors are inlined (categorical, theme-neutral) so the
+ bar reads the same in light and dark. Expects `$overview` in scope.
+--}}
+@php
+ $codes = [
+ ['label' => '2xx', 'count' => (int) ($overview['s2xx'] ?? 0), 'color' => '#3b82f6'],
+ ['label' => '3xx', 'count' => (int) ($overview['s3xx'] ?? 0), 'color' => '#eab308'],
+ ['label' => '4xx', 'count' => (int) ($overview['s4xx'] ?? 0), 'color' => '#ec4899'],
+ ['label' => '5xx', 'count' => (int) ($overview['s5xx'] ?? 0), 'color' => '#a855f7'],
+ ];
+ $total = array_sum(array_column($codes, 'count'));
+@endphp
+
+
+ {{-- Legend --}}
+
+ @foreach ($codes as $code)
+
+
+ {{ $code['label'] }}
+ {{ compactNumber($code['count']) }}
+
+ @endforeach
+
+
+ {{-- Stacked proportional bar --}}
+ @if ($total > 0)
+
+ @foreach ($codes as $code)
+ @if ($code['count'] > 0)
+ @php $pct = round($code['count'] / $total * 100, 1); @endphp
+
+ @endif
+ @endforeach
+
+
+ {{-- Cursor-following tooltip (matches the ApexCharts tooltip look). --}}
+
+
+
+
+
+
+ responses Β·
+
+
+ @else
+
+ @endif
+
diff --git a/routes/api.php b/routes/api.php
index e7ee9d4ec2..5c4db2216e 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,6 +1,8 @@
middleware(['api.ability:read']);
+ Route::get('/audit-events', [AuditEventsController::class, 'index'])->middleware(['api.ability:read']);
Route::get('/teams', [TeamController::class, 'teams'])->middleware(['api.ability:read']);
// Token's team
@@ -120,6 +124,7 @@ Route::group([
Route::get('/security/keys/{uuid}', [SecurityController::class, 'key_by_uuid'])->middleware(['api.ability:read']);
Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']);
Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:write']);
+ Route::post('/security/integration-tokens', [IntegrationTokensController::class, 'store'])->middleware(['api.ability:write']);
Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']);
Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']);
@@ -237,6 +242,7 @@ Route::group([
Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']);
Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
+ Route::patch('/applications/{uuid}/secret-manager', [ApplicationSecretManagerController::class, 'update'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/envs', [ApplicationsController::class, 'create_env'])->middleware(['api.ability:write']);
@@ -303,6 +309,9 @@ Route::group([
Route::post('/databases/keydb', [DatabasesController::class, 'create_database_keydb'])->middleware(['api.ability:write']);
Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']);
+ Route::post('/databases/{uuid}/imports/uploads', [DatabasesController::class, 'upload_import'])->middleware(['api.ability:deploy'])->name('api.databases.imports.upload');
+ Route::post('/databases/{uuid}/imports', [DatabasesController::class, 'create_import'])->middleware(['api.ability:deploy'])->name('api.databases.imports.store');
+ Route::get('/databases/{uuid}/imports/{activity_id}', [DatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->name('api.databases.imports.show');
Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']);
Route::get('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', [DatabasesController::class, 'list_backup_executions'])->middleware(['api.ability:read']);
Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
@@ -402,6 +411,9 @@ Route::group([
Route::get('/services/{uuid}/databases', [ServiceDatabasesController::class, 'index'])->middleware(['api.ability:read']);
Route::get('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'show'])->middleware(['api.ability:read']);
+ Route::post('/services/{uuid}/databases/{database_uuid}/imports/uploads', [ServiceDatabasesController::class, 'upload_import'])->middleware(['api.ability:deploy'])->name('api.service-databases.imports.upload');
+ Route::post('/services/{uuid}/databases/{database_uuid}/imports', [ServiceDatabasesController::class, 'create_import'])->middleware(['api.ability:deploy'])->name('api.service-databases.imports.store');
+ Route::get('/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', [ServiceDatabasesController::class, 'show_import'])->middleware(['api.ability:read'])->name('api.service-databases.imports.show');
Route::patch('/services/{uuid}/databases/{database_uuid}', [ServiceDatabasesController::class, 'update'])->middleware(['api.ability:write']);
Route::get('/services/{uuid}/databases/{database_uuid}/logs', [ServiceDatabasesController::class, 'logs'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/databases/{database_uuid}/start', [ServiceDatabasesController::class, 'start'])->middleware(['api.ability:deploy']);
diff --git a/routes/web.php b/routes/web.php
index 30a9634d4d..2a0b66a2ac 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -6,6 +6,7 @@ use App\Http\Controllers\ProfileAvatarController;
use App\Http\Controllers\ProjectIconController;
use App\Http\Controllers\UploadController;
use App\Livewire\Admin\Index as AdminIndex;
+use App\Livewire\Analytics;
use App\Livewire\Boarding\Index as BoardingIndex;
use App\Livewire\Dashboard;
use App\Livewire\Destination\Index as DestinationIndex;
@@ -49,10 +50,12 @@ use App\Livewire\Security\CloudInitScript\Show as SecurityCloudInitScriptShow;
use App\Livewire\Security\CloudInitScripts;
use App\Livewire\Security\CloudProviderToken\Show as SecurityCloudProviderTokenShow;
use App\Livewire\Security\CloudTokens;
+use App\Livewire\Security\IntegrationTokens;
use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex;
use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow;
use App\Livewire\SelectTeam;
use App\Livewire\Server\Advanced as ServerAdvanced;
+use App\Livewire\Server\Analytics\Show as ServerAnalytics;
use App\Livewire\Server\CaCertificate\Show as CaCertificateShow;
use App\Livewire\Server\Charts as ServerCharts;
use App\Livewire\Server\CloudflareTunnel;
@@ -99,6 +102,7 @@ use App\Livewire\Subscription\Index as SubscriptionIndex;
use App\Livewire\Subscription\Show as SubscriptionShow;
use App\Livewire\Tags\Show as TagsShow;
use App\Livewire\Team\AdminView as TeamAdminView;
+use App\Livewire\Team\AuditLog as TeamAuditLog;
use App\Livewire\Team\DangerZone as TeamDangerZone;
use App\Livewire\Team\Index as TeamIndex;
use App\Livewire\Team\Member\Index as TeamMemberIndex;
@@ -156,6 +160,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
});
Route::get('/', Dashboard::class)->name('dashboard');
+ Route::get('/analytics', Analytics::class)->name('analytics');
Route::get('/admin', AdminIndex::class)->name('admin.index');
Route::get('/onboarding', BoardingIndex::class)->name('onboarding');
@@ -169,6 +174,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup');
Route::get('/settings/email', SettingsEmail::class)->name('settings.email');
Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth');
+ Route::get('/settings/oauth/{provider}', SettingsOauth::class)
+ ->where('provider', '[A-Za-z0-9_-]+')
+ ->name('settings.oauth.provider');
Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs');
Route::get('/profile', ProfileIndex::class)->name('profile');
@@ -208,6 +216,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::prefix('team')->group(function () {
Route::get('/', TeamIndex::class)->name('team.index');
Route::get('/members', TeamMemberIndex::class)->name('team.member.index');
+ Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log');
Route::get('/admin', TeamAdminView::class)->name('team.admin-view');
Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone');
});
@@ -286,6 +295,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/resource-limits', ApplicationConfiguration::class)->name('project.application.resource-limits');
Route::get('/resource-operations', ApplicationConfiguration::class)->name('project.application.resource-operations');
Route::get('/metrics', ApplicationConfiguration::class)->name('project.application.metrics');
+ Route::get('/analytics', ApplicationConfiguration::class)->name('project.application.analytics');
Route::get('/tags', ApplicationConfiguration::class)->name('project.application.tags');
Route::get('/danger', ApplicationConfiguration::class)->name('project.application.danger');
@@ -370,6 +380,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/destinations', ServerDestinations::class)->name('server.destinations');
Route::get('/log-drains', LogDrains::class)->name('server.log-drains');
Route::get('/metrics', ServerCharts::class)->name('server.metrics');
+ Route::get('/analytics', ServerAnalytics::class)->name('server.analytics');
Route::get('/danger', DeleteServer::class)->name('server.delete');
Route::get('/transfer', ServerTransfer::class)->name('server.transfer');
Route::get('/proxy', ProxyShow::class)->name('server.proxy');
@@ -392,6 +403,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show');
Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens');
+ Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens');
Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show');
Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts');
Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show');
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index 3098e52699..c6fc73c60d 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -64,7 +64,7 @@
"category": "productivity",
"logo": "svgs/alexandrie.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-07-07T13:24:35+02:00",
+ "template_last_updated_at": "2026-04-05T13:36:24+02:00",
"port": "8200"
},
"anythingllm": {
@@ -1370,7 +1370,7 @@
"category": "productivity",
"logo": "svgs/espocrm.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-07-03T15:15:43+03:00",
+ "template_last_updated_at": "2026-04-06T11:35:16-05:00",
"port": "80"
},
"evolution-api": {
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 1574f77685..c13c02896d 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -64,7 +64,7 @@
"category": "productivity",
"logo": "svgs/alexandrie.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-07-07T13:24:35+02:00",
+ "template_last_updated_at": "2026-04-05T13:36:24+02:00",
"port": "8200"
},
"anythingllm": {
@@ -1370,7 +1370,7 @@
"category": "productivity",
"logo": "svgs/espocrm.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-07-03T15:15:43+03:00",
+ "template_last_updated_at": "2026-04-06T11:35:16-05:00",
"port": "80"
},
"evolution-api": {
diff --git a/tests/Browser/LoginTest.php b/tests/Browser/LoginTest.php
deleted file mode 100644
index d20e652946..0000000000
--- a/tests/Browser/LoginTest.php
+++ /dev/null
@@ -1,27 +0,0 @@
-browse(callback: function (Browser $browser) {
- $browser->loginWithRootUser()
- ->assertPathIs('/')
- ->assertSee('Dashboard');
- });
- }
-}
diff --git a/tests/Browser/Project/ProjectAddNewTest.php b/tests/Browser/Project/ProjectAddNewTest.php
deleted file mode 100644
index b03313e4b0..0000000000
--- a/tests/Browser/Project/ProjectAddNewTest.php
+++ /dev/null
@@ -1,34 +0,0 @@
-browse(function (Browser $browser) {
- $browser->loginWithRootUser()
- ->visit('/projects')
- ->pressAndWaitFor('+ Add', 1)
- ->assertSee('New Project')
- ->screenshot('project-add-new-1')
- ->type('name', 'Test Project')
- ->screenshot('project-add-new-2')
- ->press('Continue')
- ->assertSee('Test Project.')
- ->screenshot('project-add-new-3');
- });
- }
-}
diff --git a/tests/Browser/Project/ProjectSearchTest.php b/tests/Browser/Project/ProjectSearchTest.php
deleted file mode 100644
index 7bc6796d10..0000000000
--- a/tests/Browser/Project/ProjectSearchTest.php
+++ /dev/null
@@ -1,29 +0,0 @@
-browse(function (Browser $browser) {
- $browser->loginWithRootUser()
- ->visit('/projects')
- ->type('[x-model="search"]', 'joi43j4oi32j4o2')
- ->assertSee('No project found with the search term "joi43j4oi32j4o2".')
- ->screenshot('project-search-not-found');
- });
- }
-}
diff --git a/tests/Browser/Project/ProjectTest.php b/tests/Browser/Project/ProjectTest.php
deleted file mode 100644
index 0d360e4604..0000000000
--- a/tests/Browser/Project/ProjectTest.php
+++ /dev/null
@@ -1,27 +0,0 @@
-browse(function (Browser $browser) {
- $browser->loginWithRootUser()
- ->visit('/projects')
- ->assertSee('Projects');
- });
- }
-}
diff --git a/tests/Browser/console/.gitignore b/tests/Browser/console/.gitignore
deleted file mode 100644
index d6b7ef32c8..0000000000
--- a/tests/Browser/console/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/tests/Browser/source/.gitignore b/tests/Browser/source/.gitignore
deleted file mode 100644
index d6b7ef32c8..0000000000
--- a/tests/Browser/source/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php
deleted file mode 100644
index 98e90fa79c..0000000000
--- a/tests/DuskTestCase.php
+++ /dev/null
@@ -1,57 +0,0 @@
-addArguments(collect([
- $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
- ])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
- return $items->merge([
- '--disable-gpu',
- '--headless=new',
- ]);
- })->all());
-
- return RemoteWebDriver::create(
- 'http://localhost:4444',
- DesiredCapabilities::chrome()->setCapability(
- ChromeOptions::CAPABILITY,
- $options
- )
- );
- }
-
- /**
- * Determine if the browser window should start maximized.
- */
- protected function baseUrl()
- {
- return 'http://localhost:8000';
- }
-}
diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php
index a4c22a2b7c..578b70acfe 100644
--- a/tests/Feature/Api/ApplicationSettingsApiTest.php
+++ b/tests/Feature/Api/ApplicationSettingsApiTest.php
@@ -467,3 +467,32 @@ test('rejects swarm fields on application update', function (string $field, mixe
'swarm_placement_constraints' => ['swarm_placement_constraints', 'node.role==worker'],
'is_swarm_only_worker_nodes' => ['is_swarm_only_worker_nodes', true],
]);
+
+test('PATCH /api/v1/applications/{uuid} saves a slugged container name prefix', function () {
+ $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_container_name_prefix' => 'My API'])
+ ->assertOk();
+
+ expect($this->application->fresh()->settings->custom_container_name_prefix)->toBe('my-api');
+
+ $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
+ ->getJson("/api/v1/applications/{$this->application->uuid}")
+ ->assertOk()
+ ->assertJsonPath('settings.custom_container_name_prefix', 'my-api');
+});
+
+test('PATCH /api/v1/applications/{uuid} rejects a container name prefix that is in use', function () {
+ $otherApplication = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+ $otherApplication->settings->update(['custom_container_name_prefix' => 'shared-prefix']);
+
+ $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_container_name_prefix' => 'shared-prefix'])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors('custom_container_name_prefix');
+
+ expect($this->application->fresh()->settings->custom_container_name_prefix)->toBeNull();
+});
diff --git a/tests/Feature/Api/DatabaseImportApiTest.php b/tests/Feature/Api/DatabaseImportApiTest.php
new file mode 100644
index 0000000000..abd0626af4
--- /dev/null
+++ b/tests/Feature/Api/DatabaseImportApiTest.php
@@ -0,0 +1,164 @@
+ 0, 'is_api_enabled' => true]);
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+ $this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['deploy', 'read'], 'team_id' => $this->team->id]);
+ $this->headers = ['Authorization' => 'Bearer '.$this->token->id.'|secret'];
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::firstOrCreate(['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']);
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+test('validates standalone import source and hides foreign databases', function () {
+ $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
+
+ $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$database->uuid}/imports", ['source' => 'upload', 'path' => '../bad'])
+ ->assertUnprocessable()->assertJsonValidationErrors(['upload_id', 'path']);
+
+ $otherTeam = Team::factory()->create();
+ $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
+ $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
+ $foreign = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'foreign', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $otherEnvironment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
+
+ $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$foreign->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertNotFound();
+});
+
+test('requires deploy ability to start standalone import', function () {
+ $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
+ $read = $this->user->createToken('read', ['read']);
+
+ $this->withToken($read->plainTextToken)->postJson("/api/v1/databases/{$database->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertForbidden();
+});
+
+test('audits a successfully queued standalone import', function () {
+ $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
+ $activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]);
+ $action = Mockery::mock(StartDatabaseImport::class);
+ $action->shouldReceive('handle')->once()->andReturn($activity);
+ app()->instance(StartDatabaseImport::class, $action);
+
+ $this->withHeaders($this->headers)
+ ->postJson("/api/v1/databases/{$database->uuid}/imports", ['source' => 'server', 'path' => '/tmp/backup.sql'])
+ ->assertAccepted();
+
+ $event = AuditEvent::query()->where('event', 'api.database.import_started')->sole();
+
+ expect($event->team_id)->toBe($this->team->id)
+ ->and($event->resource_uuid)->toBe($database->uuid)
+ ->and($event->resource_name)->toBe($database->name)
+ ->and($event->metadata['source'])->toBe('server')
+ ->and($event->metadata['activity_id'])->toBe($activity->id);
+});
+
+test('passes the replace existing option to a standalone database import', function () {
+ $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
+ $activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]);
+ $action = Mockery::mock(StartDatabaseImport::class);
+ $action->shouldReceive('handle')->once()->withArgs(fn ($resource, $source, $teamId) => $resource->is($database)
+ && $source->replaceExisting === true
+ && $teamId === $this->team->id)->andReturn($activity);
+ app()->instance(StartDatabaseImport::class, $action);
+
+ $this->withHeaders($this->headers)
+ ->postJson("/api/v1/databases/{$database->uuid}/imports", [
+ 'source' => 'server',
+ 'path' => '/tmp/backup.dump',
+ 'replace_existing' => true,
+ ])
+ ->assertAccepted();
+});
+
+test('validates replace existing as a boolean', function () {
+ $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
+
+ $this->withHeaders($this->headers)
+ ->postJson("/api/v1/databases/{$database->uuid}/imports", [
+ 'source' => 'server',
+ 'path' => '/tmp/backup.dump',
+ 'replace_existing' => 'yes',
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors('replace_existing');
+});
+
+test('rejects unknown fields on standalone import', function () {
+ $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
+ $activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]);
+ $action = Mockery::mock(StartDatabaseImport::class);
+ $action->shouldReceive('handle')->andReturn($activity);
+ app()->instance(StartDatabaseImport::class, $action);
+
+ $this->withHeaders($this->headers)
+ ->postJson("/api/v1/databases/{$database->uuid}/imports", [
+ 'source' => 'server',
+ 'path' => '/tmp/backup.sql',
+ 'unknown_field' => 'nope',
+ ])
+ ->assertUnprocessable()
+ ->assertJsonPath('errors.unknown_field.0', 'This field is not allowed.');
+});
+
+test('returns only a team and resource scoped import activity', function () {
+ $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
+ $activity = Activity::create(['log_name' => 'default', 'description' => json_encode([['order' => 1, 'output' => 'restored', 'type' => 'stdout']]), 'properties' => ['team_id' => $this->team->id, 'type_uuid' => $database->uuid, 'operation' => 'database_import', 'status' => 'finished', 'exitCode' => 0]]);
+
+ $this->withHeaders($this->headers)->getJson("/api/v1/databases/{$database->uuid}/imports/{$activity->id}")
+ ->assertOk()->assertJson(['id' => $activity->id, 'status' => 'finished', 'exit_code' => 0, 'output' => 'restored'])
+ ->assertJsonMissingPath('command');
+
+ $activity->properties = $activity->properties->merge(['team_id' => $this->team->id + 1]);
+ $activity->save();
+ $this->withHeaders($this->headers)->getJson("/api/v1/databases/{$database->uuid}/imports/{$activity->id}")->assertNotFound();
+});
+
+test('returns invalid token when the access token team is not a member team', function (string $method, string $path) {
+ $this->withoutMiddleware([
+ EnsureTokenBelongsToCurrentTeamMember::class,
+ ApiAbility::class,
+ ]);
+
+ $database = StandalonePostgresql::create(['uuid' => (string) Str::uuid(), 'name' => 'db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', 'image' => 'postgres:17', 'status' => 'running', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass()]);
+ $foreignTeam = Team::factory()->create();
+ $plainTextToken = 'no-team';
+ $token = $this->user->tokens()->create([
+ 'name' => 'imports-foreign-team',
+ 'token' => hash('sha256', $plainTextToken),
+ 'abilities' => ['deploy', 'read'],
+ 'team_id' => $foreignTeam->id,
+ ]);
+
+ $this->withHeaders(['Authorization' => 'Bearer '.$token->id.'|'.$plainTextToken])
+ ->{$method}(sprintf($path, $database->uuid))
+ ->assertBadRequest()
+ ->assertJson([
+ 'message' => 'Invalid token.',
+ 'docs' => 'https://coolify.io/docs/api-reference/authorization',
+ ]);
+})->with([
+ 'upload' => ['postJson', '/api/v1/databases/%s/imports/uploads'],
+ 'create' => ['postJson', '/api/v1/databases/%s/imports'],
+ 'show' => ['getJson', '/api/v1/databases/%s/imports/1'],
+]);
diff --git a/tests/Feature/Api/DatabaseImportRoutesTest.php b/tests/Feature/Api/DatabaseImportRoutesTest.php
new file mode 100644
index 0000000000..a91e160356
--- /dev/null
+++ b/tests/Feature/Api/DatabaseImportRoutesTest.php
@@ -0,0 +1,21 @@
+getRoutesByName());
+
+ $expected = [
+ 'api.databases.imports.upload' => 'api.ability:deploy',
+ 'api.databases.imports.store' => 'api.ability:deploy',
+ 'api.databases.imports.show' => 'api.ability:read',
+ 'api.service-databases.imports.upload' => 'api.ability:deploy',
+ 'api.service-databases.imports.store' => 'api.ability:deploy',
+ 'api.service-databases.imports.show' => 'api.ability:read',
+ ];
+
+ foreach ($expected as $name => $ability) {
+ expect($routes)->toHaveKey($name);
+ expect($routes[$name]->gatherMiddleware())->toContain($ability);
+ }
+});
diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php
index 0359984fd1..cdeb762170 100644
--- a/tests/Feature/Api/ServerSubsystemsApiTest.php
+++ b/tests/Feature/Api/ServerSubsystemsApiTest.php
@@ -217,7 +217,9 @@ describe('Sentinel API', function () {
->getJson("/api/v1/servers/{$this->server->uuid}/sentinel")
->assertOk()
->assertJsonPath('is_sentinel_enabled', true)
- ->assertJsonPath('is_metrics_enabled', true);
+ ->assertJsonPath('is_metrics_enabled', true)
+ ->assertJsonPath('traffic_topn', 50)
+ ->assertJsonPath('is_geoip_enabled', true);
expect($response->json())->not->toHaveKey('sentinel_token')
->and($response->json())->not->toHaveKey('sentinel_custom_url');
diff --git a/tests/Feature/Api/ServiceDatabaseImportApiTest.php b/tests/Feature/Api/ServiceDatabaseImportApiTest.php
new file mode 100644
index 0000000000..df591e97cb
--- /dev/null
+++ b/tests/Feature/Api/ServiceDatabaseImportApiTest.php
@@ -0,0 +1,73 @@
+ 0, 'is_api_enabled' => true]);
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+ $this->token = $this->user->tokens()->create(['name' => 'imports', 'token' => hash('sha256', 'secret'), 'abilities' => ['deploy', 'read'], 'team_id' => $this->team->id]);
+ $this->headers = ['Authorization' => 'Bearer '.$this->token->id.'|secret'];
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::firstOrCreate(['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'docker']);
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+test('validates service database imports and binds database to service', function () {
+ $service = Service::factory()->create(['environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services:\n postgres:\n image: postgres:17\n"]);
+ $database = ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'name' => 'postgres', 'service_id' => $service->id, 'image' => 'postgres:17']);
+
+ $url = "/api/v1/services/{$service->uuid}/databases/{$database->uuid}/imports";
+ $this->withHeaders($this->headers)->postJson($url, ['source' => 's3', 'upload_id' => (string) Str::uuid()])
+ ->assertUnprocessable()->assertJsonValidationErrors(['upload_id', 's3_storage_uuid', 'path']);
+
+ $otherService = Service::factory()->create(['environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services: {}\n"]);
+ $this->withHeaders($this->headers)->postJson("/api/v1/services/{$otherService->uuid}/databases/{$database->uuid}/imports", ['source' => 'server', 'path' => '/tmp/a.sql'])->assertNotFound();
+});
+
+test('returns invalid token when the access token team is not a member team', function (string $method, string $suffix) {
+ $this->withoutMiddleware([
+ EnsureTokenBelongsToCurrentTeamMember::class,
+ ApiAbility::class,
+ ]);
+
+ $service = Service::factory()->create(['environment_id' => $this->environment->id, 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), 'docker_compose_raw' => "services:\n postgres:\n image: postgres:17\n"]);
+ $database = ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'name' => 'postgres', 'service_id' => $service->id, 'image' => 'postgres:17']);
+ $foreignTeam = Team::factory()->create();
+ $plainTextToken = 'no-team';
+ $token = $this->user->tokens()->create([
+ 'name' => 'imports-foreign-team',
+ 'token' => hash('sha256', $plainTextToken),
+ 'abilities' => ['deploy', 'read'],
+ 'team_id' => $foreignTeam->id,
+ ]);
+
+ $this->withHeaders(['Authorization' => 'Bearer '.$token->id.'|'.$plainTextToken])
+ ->{$method}("/api/v1/services/{$service->uuid}/databases/{$database->uuid}/{$suffix}")
+ ->assertBadRequest()
+ ->assertJson([
+ 'message' => 'Invalid token.',
+ 'docs' => 'https://coolify.io/docs/api-reference/authorization',
+ ]);
+})->with([
+ 'upload' => ['postJson', 'imports/uploads'],
+ 'create' => ['postJson', 'imports'],
+ 'show' => ['getJson', 'imports/1'],
+]);
diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
index 87b3407cb5..afec2fe9fb 100644
--- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
+++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
@@ -16,6 +16,68 @@ use Symfony\Component\Process\Process;
uses(RefreshDatabase::class);
+it('does not persist environment write commands or generated Dockerfiles in deployment logs', function () {
+ [$application, $server] = makeDeploymentControlVarFixture();
+
+ createApplicationEnvironmentVariable($application, [
+ 'key' => 'APP_SECRET',
+ 'value' => 'sensitive-value',
+ ]);
+
+ [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
+ 'configuration_dir' => '/data/coolify/applications/test-app',
+ 'remote_secrets_cache' => [],
+ 'saved_outputs' => [
+ 'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
+ ],
+ ]);
+
+ invokeDeploymentJobMethod($job, $reflection, 'save_runtime_environment_variables');
+ invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables');
+ invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
+
+ $writeCommands = collect($job->recordedCommands)
+ ->flatMap(fn (array $commands): array => $commands)
+ ->filter(function (mixed $command): bool {
+ if (! is_array($command)) {
+ return false;
+ }
+
+ $commandString = $command['command'] ?? $command[0] ?? null;
+
+ return is_string($commandString) && str_contains($commandString, 'base64 -d | tee');
+ })
+ ->values();
+
+ expect($writeCommands)->toHaveCount(4)
+ ->each->toHaveKey('skip_command_log', true);
+});
+
+it('redacts resolved remote secrets from command output', function () {
+ [$application, $server] = makeDeploymentControlVarFixture();
+ [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
+ 'remote_secrets_cache' => ['API_TOKEN' => 'remote-secret-value'],
+ ]);
+
+ expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'token=remote-secret-value'))
+ ->toBe('token='.REDACTED);
+});
+
+it('ignores empty and non-string remote secrets when redacting command output', function () {
+ [$application, $server] = makeDeploymentControlVarFixture();
+ [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
+ 'remote_secrets_cache' => [
+ 'EMPTY_SECRET' => '',
+ 'NULL_SECRET' => null,
+ 'NUMERIC_SECRET' => 123,
+ 'API_TOKEN' => 'remote-secret-value',
+ ],
+ ]);
+
+ expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'id=123 token=remote-secret-value'))
+ ->toBe('id=123 token='.REDACTED);
+});
+
class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
@@ -756,10 +818,50 @@ it('filters buildpack control vars from dockerfile arg injection', function () {
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production');
+ expect($job->writtenDockerfile)->toContain('ARG COOLIFY_BUILD_SECRETS_HASH=');
expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION=');
expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
});
+it('injects raw escaped remote secrets into Dockerfile args and hashes the same values', function (int $pullRequestId, bool $isPreview) {
+ [$application, $server] = makeDeploymentControlVarFixture();
+
+ createApplicationEnvironmentVariable($application, [
+ 'key' => 'SECRET_TOKEN',
+ 'value' => '{{vault.API_TOKEN}}',
+ 'is_preview' => $isPreview,
+ 'is_runtime' => false,
+ 'is_buildtime' => true,
+ ]);
+
+ $secret = "secret\$value'quoted";
+ $escapedSecret = escapeBashEnvValue($secret);
+ [$job, $reflection] = makeControlVarFilteringJob($application, $server, [
+ 'pull_request_id' => $pullRequestId,
+ 'remote_secrets_cache' => ['API_TOKEN' => $secret],
+ 'saved_outputs' => [
+ 'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
+ ],
+ ]);
+
+ invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
+
+ $expectedHash = invokeDeploymentJobMethod(
+ $job,
+ $reflection,
+ 'generate_secrets_hash',
+ collect(['SECRET_TOKEN' => $escapedSecret]),
+ );
+
+ expect($job->writtenDockerfile)
+ ->toContain("ARG SECRET_TOKEN={$escapedSecret}")
+ ->toContain("ARG COOLIFY_BUILD_SECRETS_HASH={$expectedHash}")
+ ->not->toContain('$$');
+})->with([
+ 'production' => [0, false],
+ 'preview' => [99, true],
+]);
+
it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php
index d7be33df7d..74c9db670f 100644
--- a/tests/Feature/ApplicationDomainsTest.php
+++ b/tests/Feature/ApplicationDomainsTest.php
@@ -1,13 +1,17 @@
toBe(['https://app.example.com', 'https://www.app.example.com']);
});
+it('does not dispatch configure dns jobs when the server ip is missing or invalid', function () {
+ Queue::fake();
+
+ $this->server->update(['ip' => 'not-an-ip']);
+
+ $token = IntegrationToken::factory()->for($this->team)->create([
+ 'provider' => 'cloudflare',
+ 'capabilities' => ['dns'],
+ ]);
+ DnsProviderZone::factory()->for($token)->create(['name' => 'example.com']);
+
+ Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->set('newDomain', 'https://app.example.com')
+ ->call('addDomain')
+ ->assertHasNoErrors()
+ ->assertNotDispatched('error');
+
+ expect(explode(',', (string) $this->application->fresh()->fqdn))
+ ->toContain('https://app.example.com');
+
+ Queue::assertNotPushed(ConfigureDnsRecordJob::class);
+});
+
it('composes the complete port on the server without duplicating an existing www domain', function () {
$this->application->update(['fqdn' => 'https://www.example.com:3000']);
@@ -982,6 +1010,79 @@ it('removes consecutive domains by stable row identity after indexes change', fu
expect($this->application->fresh()->fqdn)->toBe('https://third.example.com');
});
+it('deletes the managed dns record when removing a domain by key with deleteManagedDns', function () {
+ $this->application->update(['fqdn' => 'https://app.example.com']);
+
+ $token = IntegrationToken::factory()->for($this->team)->create([
+ 'provider' => 'cloudflare',
+ 'token' => 'secret',
+ ]);
+ $zone = DnsProviderZone::factory()->for($token)->create([
+ 'provider_zone_id' => 'zone-1',
+ 'name' => 'example.com',
+ ]);
+ $record = ManagedDnsRecord::factory()->create([
+ 'team_id' => $this->team->id,
+ 'integration_token_id' => $token->id,
+ 'dns_provider_zone_id' => $zone->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ 'resource_id' => $this->application->getKey(),
+ 'provider_record_id' => 'record-1',
+ 'type' => 'A',
+ 'name' => 'app.example.com',
+ 'content' => '203.0.113.10',
+ ]);
+
+ Http::fake(['https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::sequence()
+ ->push(['success' => true, 'result' => [
+ 'id' => 'record-1',
+ 'type' => 'A',
+ 'name' => 'app.example.com',
+ 'content' => '203.0.113.10',
+ ]])
+ ->push(['success' => true, 'result' => ['id' => 'record-1']])]);
+
+ $domainKey = hash('sha256', 'https://app.example.com|');
+
+ Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->call('removeDomainByKey', $domainKey, '', ['deleteManagedDns'])
+ ->assertDispatched('success');
+
+ expect($this->application->fresh()->fqdn)->toBeNull()
+ ->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
+});
+
+it('leaves the managed dns record when removing a domain by key without deleteManagedDns', function () {
+ $this->application->update(['fqdn' => 'https://app.example.com']);
+
+ $token = IntegrationToken::factory()->for($this->team)->create([
+ 'provider' => 'cloudflare',
+ 'token' => 'secret',
+ ]);
+ $zone = DnsProviderZone::factory()->for($token)->create([
+ 'provider_zone_id' => 'zone-1',
+ 'name' => 'example.com',
+ ]);
+ $record = ManagedDnsRecord::factory()->create([
+ 'team_id' => $this->team->id,
+ 'integration_token_id' => $token->id,
+ 'dns_provider_zone_id' => $zone->id,
+ 'provider_record_id' => 'record-1',
+ 'type' => 'A',
+ 'name' => 'app.example.com',
+ 'content' => '203.0.113.10',
+ ]);
+
+ $domainKey = hash('sha256', 'https://app.example.com|');
+
+ Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->call('removeDomainByKey', $domainKey, '')
+ ->assertDispatched('success');
+
+ expect($this->application->fresh()->fqdn)->toBeNull()
+ ->and(ManagedDnsRecord::query()->find($record->id))->not->toBeNull();
+});
+
it('does not revalidate dns on remaining domains when removing one', function () {
$settings = InstanceSettings::get();
$settings->is_dns_validation_enabled = true;
@@ -1232,6 +1333,60 @@ it('hides dns check buttons from members', function () {
->assertDontSee('Check DNS');
});
+it('disables create dns record for members and hides replace confirmation', function () {
+ $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
+ $this->actingAs($this->user->fresh());
+
+ $proposal = [
+ 'hostname' => 'app.example.com',
+ 'zone_id' => 1,
+ 'zone' => 'example.com',
+ 'credential' => 'Cloudflare',
+ 'target' => '203.0.113.10',
+ 'managed' => false,
+ ];
+
+ $createHtml = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->set('showDnsProviderModal', true)
+ ->set('dnsProviderProposals', [$proposal])
+ ->html();
+
+ expect($createHtml)->toContain('Create DNS record')
+ ->toMatch('/]*\sdisabled(?:[=\s>])[^>]*>.*?Create DNS record/s');
+
+ $replaceHtml = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->set('showDnsProviderModal', true)
+ ->set('dnsProviderProposals', [$proposal])
+ ->set('dnsProviderConflicts', [
+ 'app.example.com|1' => [
+ 'record_id' => 'rec-1',
+ 'current' => '198.51.100.10',
+ 'proposed' => '203.0.113.10',
+ ],
+ ])
+ ->html();
+
+ expect($replaceHtml)->toContain('Currently 198.51.100.10')
+ ->toMatch('/]*\sdisabled(?:[=\s>])[^>]*>.*?Replace record/s');
+});
+
+it('shows create dns record enabled for owners', function () {
+ $html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
+ ->set('showDnsProviderModal', true)
+ ->set('dnsProviderProposals', [[
+ 'hostname' => 'app.example.com',
+ 'zone_id' => 1,
+ 'zone' => 'example.com',
+ 'credential' => 'Cloudflare',
+ 'target' => '203.0.113.10',
+ 'managed' => false,
+ ]])
+ ->html();
+
+ expect($html)->toContain('Create DNS record')
+ ->not->toMatch('/]*\sdisabled(?:[=\s>])[^>]*>.*?Create DNS record/s');
+});
+
it('loads persisted dns status on page load', function () {
$this->application->update([
'fqdn' => 'https://app.example.com',
@@ -1431,7 +1586,7 @@ it('resolves hostname server addresses to a real ip for dns messages', function
// Failed checks show required DNS record guidance; ok checks mention the hostname label.
if ($component->get('domainRows.0.dns_status') === 'failed') {
- expect($message)->toBe("{$recordType} record β {$resolvedIp}")
+ expect($message)->toBe("Required DNS record type {$recordType} pointing to {$resolvedIp}")
->and($message)->not->toContain('CNAME');
} else {
expect($message)->toContain($resolvedIp)
diff --git a/tests/Feature/ApplicationGeneralLayoutTest.php b/tests/Feature/ApplicationGeneralLayoutTest.php
index 41f62022af..ceff52b3e7 100644
--- a/tests/Feature/ApplicationGeneralLayoutTest.php
+++ b/tests/Feature/ApplicationGeneralLayoutTest.php
@@ -21,6 +21,12 @@ test('compose file loading waits for the user to confirm the file location', fun
->not->toContain('x-init="$wire.dispatch(\'loadCompose\', true)"');
});
+test('traffic analytics is only shown on the dedicated analytics page', function () {
+ $view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
+
+ expect($view)->not->toContain('withoutDefer();
+
+ InstanceSettings::forceCreate(['id' => 0]);
+ Once::flush();
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ Log::spy();
+});
+
+test('audit inserts are deferred until after the response', function () {
+ $this->withDefer();
+
+ auditLog('ui.project.updated', [
+ 'team_id' => $this->team->id,
+ 'project_uuid' => 'project-123',
+ 'project_name' => 'Website',
+ ]);
+
+ expect(AuditEvent::query()->count())->toBe(0);
+
+ defer()->invoke();
+
+ expect(AuditEvent::query()->count())->toBe(1);
+});
+
+test('multiple audit inserts in one request are all deferred', function () {
+ $this->withDefer();
+
+ auditLog('ui.application.deployed', [
+ 'team_id' => $this->team->id,
+ 'application_uuid' => 'app-123',
+ ]);
+ auditLog('ui.application.updated', [
+ 'team_id' => $this->team->id,
+ 'application_uuid' => 'app-123',
+ ]);
+
+ defer()->invoke();
+
+ expect(AuditEvent::query()->pluck('event')->all())->toBe([
+ 'ui.application.deployed',
+ 'ui.application.updated',
+ ]);
+});
+
+test('http kernel invokes deferred callbacks', function () {
+ $kernel = app(Kernel::class);
+ $middleware = (new ReflectionClass($kernel))->getProperty('middleware')->getValue($kernel);
+
+ expect($middleware)->toContain(InvokeDeferredCallbacks::class);
+});
+
+test('audit persistence failures do not fail the action', function () {
+ Schema::rename('audit_events', 'unavailable_audit_events');
+
+ try {
+ expect(fn () => auditLog('ui.project.updated', ['team_id' => $this->team->id]))
+ ->not->toThrow(Throwable::class);
+
+ Log::shouldHaveReceived('warning')->once()->with(
+ 'Audit event persistence failed',
+ Mockery::on(fn (array $context): bool => $context === [
+ 'event' => 'ui.project.updated',
+ 'exception' => QueryException::class,
+ ]),
+ );
+ } finally {
+ Schema::rename('unavailable_audit_events', 'audit_events');
+ }
+});
+
+test('audit preparation failures log sanitized diagnostics without failing the action', function () {
+ $resourceName = new class
+ {
+ public function __toString(): string
+ {
+ throw new RuntimeException('sensitive audit metadata');
+ }
+ };
+
+ expect(fn () => auditLog('ui.project.updated', [
+ 'team_id' => $this->team->id,
+ 'project_name' => $resourceName,
+ 'secret' => 'must not be logged',
+ ]))->not->toThrow(Throwable::class);
+
+ Log::shouldHaveReceived('warning')->once()->with(
+ 'Audit event preparation failed',
+ Mockery::on(fn (array $context): bool => $context === [
+ 'event' => 'ui.project.updated',
+ 'exception' => RuntimeException::class,
+ ]),
+ );
+});
+
+test('audit log persists a structured event for the current team', function () {
+ auditLog('ui.application.updated', [
+ 'application_uuid' => 'app-123',
+ 'application_name' => 'Website',
+ 'changed' => ['name'],
+ ]);
+
+ $event = AuditEvent::query()->sole();
+
+ expect($event->team_id)->toBe($this->team->id)
+ ->and($event->actor_id)->toBe($this->user->id)
+ ->and($event->actor_email)->toBe($this->user->email)
+ ->and($event->source)->toBe('ui')
+ ->and($event->action)->toBe('updated')
+ ->and($event->resource_type)->toBe('application')
+ ->and($event->resource_uuid)->toBe('app-123')
+ ->and($event->resource_name)->toBe('Website')
+ ->and($event->metadata['changed'])->toBe(['name']);
+});
+
+test('auditable models record authenticated create update and delete actions', function () {
+ $project = Project::factory()->create([
+ 'team_id' => $this->team->id,
+ 'name' => 'Website project',
+ ]);
+ $project->update(['name' => 'Renamed project']);
+ $project->delete();
+
+ $events = AuditEvent::query()->where('resource_type', 'project')->orderBy('id')->get();
+
+ expect($events->pluck('event')->all())->toBe([
+ 'ui.project.created',
+ 'ui.project.updated',
+ 'ui.project.deleted',
+ ])->and($events[1]->metadata['changed_fields'])->toBe(['name']);
+});
+
+test('deleting a project dispatches deleted events for its environments', function () {
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = $project->environments()->sole();
+ AuditEvent::query()->delete();
+
+ $project->delete();
+
+ expect(AuditEvent::query()
+ ->where('event', 'ui.environment.deleted')
+ ->where('resource_uuid', $environment->uuid)
+ ->exists())->toBeTrue();
+});
+
+test('deleting a team dispatches updated events for transferred system-wide sources', function () {
+ Team::factory()->create(['id' => 0]);
+ $githubApp = GithubApp::query()->create([
+ 'name' => 'System GitHub source',
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => true,
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ ]);
+ $gitlabApp = GitlabApp::query()->create([
+ 'name' => 'System GitLab source',
+ 'team_id' => $this->team->id,
+ 'is_system_wide' => true,
+ 'api_url' => 'https://gitlab.com/api/v4',
+ 'html_url' => 'https://gitlab.com',
+ ]);
+ AuditEvent::query()->delete();
+
+ $this->team->delete();
+
+ expect(AuditEvent::query()
+ ->where('event', 'ui.github_app.updated')
+ ->where('resource_uuid', $githubApp->uuid)
+ ->exists())->toBeTrue()
+ ->and(AuditEvent::query()
+ ->where('event', 'ui.gitlab_app.updated')
+ ->where('resource_uuid', $gitlabApp->uuid)
+ ->exists())->toBeTrue();
+});
+
+test('auditable model mutations succeed when audit persistence fails', function () {
+ Schema::rename('audit_events', 'unavailable_audit_events');
+
+ try {
+ $project = Project::factory()->create([
+ 'team_id' => $this->team->id,
+ 'name' => 'Persisted project',
+ ]);
+ } finally {
+ Schema::rename('unavailable_audit_events', 'audit_events');
+ }
+
+ expect($project->exists)->toBeTrue()
+ ->and(Project::query()->whereKey($project->id)->exists())->toBeTrue();
+});
+
+test('repeated events for the same resource are each persisted', function () {
+ auditLog('api.project.updated', [
+ 'team_id' => $this->team->id,
+ 'project_uuid' => 'project-123',
+ 'changed_fields' => ['name'],
+ ]);
+ auditLog('api.project.updated', [
+ 'team_id' => $this->team->id,
+ 'project_uuid' => 'project-123',
+ 'changed_fields' => ['description'],
+ ]);
+
+ $events = AuditEvent::query()->orderBy('id')->get();
+
+ expect($events)->toHaveCount(2)
+ ->and($events[0]->metadata['changed_fields'])->toBe(['name'])
+ ->and($events[1]->metadata['changed_fields'])->toBe(['description']);
+});
+
+test('automatic and explicit auditing both preserve their events', function () {
+ $project = Project::factory()->create([
+ 'team_id' => $this->team->id,
+ 'name' => 'Website project',
+ ]);
+
+ auditLog('ui.project.created', [
+ 'team_id' => $this->team->id,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ 'audit_description' => 'Project created through the API',
+ 'request_field' => 'preserved',
+ ]);
+
+ $events = AuditEvent::query()->where('event', 'ui.project.created')->orderBy('id')->get();
+
+ expect($events)->toHaveCount(2)
+ ->and($events[1]->description)->toBe('Project created through the API')
+ ->and($events[1]->metadata['request_field'])->toBe('preserved');
+});
+
+test('auditable models ignore unauthenticated mutations', function () {
+ auth()->logout();
+
+ Project::factory()->create(['team_id' => $this->team->id]);
+
+ expect(AuditEvent::query()->count())->toBe(0);
+});
+
+test('webhook audits resolve the team from the application', function () {
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $application = Application::factory()->create(['environment_id' => $environment->id]);
+ AuditEvent::query()->delete();
+ auth()->logout();
+ session()->forget('currentTeam');
+
+ auditLog('webhook.deployment.queued', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ $this->assertDatabaseHas('audit_events', [
+ 'team_id' => $this->team->id,
+ 'event' => 'webhook.deployment.queued',
+ 'resource_uuid' => $application->uuid,
+ ]);
+});
+
+test('unauthenticated webhook failures without a team are preserved', function () {
+ auth()->logout();
+ session()->forget('currentTeam');
+
+ auditLogWebhookFailure('sentinel', 'token_missing');
+ auditLogWebhookFailure('stripe', 'invalid_signature');
+
+ $events = AuditEvent::query()->orderBy('id')->get();
+
+ expect($events)->toHaveCount(2)
+ ->and($events->pluck('event')->all())->toBe([
+ 'webhook.sentinel.signature_failed',
+ 'webhook.stripe.signature_failed',
+ ])
+ ->and($events->pluck('team_id')->all())->toBe([null, null]);
+});
+
+test('early Sentinel and Stripe rejections persist unscoped audit events', function () {
+ auth()->logout();
+ session()->forget('currentTeam');
+
+ $this->postJson('/api/v1/sentinel/push', [])->assertUnauthorized();
+
+ config(['subscription.stripe_webhook_secret' => 'whsec_test']);
+ $this->withHeader('Stripe-Signature', 'invalid')
+ ->call('POST', '/webhooks/payments/stripe/events', [], [], [], [], '{}')
+ ->assertBadRequest();
+
+ expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([
+ 'webhook.sentinel.signature_failed',
+ 'webhook.stripe.signature_failed',
+ ])->and(AuditEvent::query()->whereNotNull('team_id')->doesntExist())->toBeTrue();
+});
+
+test('unscoped audit events are only visible to the instance team', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => null,
+ 'description' => 'Unscoped security failure',
+ ]);
+
+ Livewire::test(AuditLog::class)
+ ->assertDontSee('Unscoped security failure');
+
+ $instanceTeam = Team::factory()->create(['id' => 0]);
+ $instanceTeam->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->user->unsetRelation('teams');
+ session(['currentTeam' => $instanceTeam]);
+
+ Livewire::test(AuditLog::class)
+ ->assertSee('Unscoped security failure');
+});
+
+test('auditable models identify personal access token mutations as api events', function () {
+ $newToken = $this->user->createToken('audit-api');
+ $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ $this->actingAs($this->user->withAccessToken($newToken->accessToken->fresh()));
+
+ Project::factory()->create(['team_id' => $this->team->id]);
+
+ expect(AuditEvent::query()->where('resource_type', 'project')->firstOrFail()->event)
+ ->toBe('api.project.created');
+});
+
+test('API audit events identify the responsible access token', function () {
+ $firstToken = $this->user->createToken('first-token');
+ $firstToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ $secondToken = $this->user->createToken('second-token');
+ $secondToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
+
+ foreach ([$firstToken->accessToken->fresh(), $secondToken->accessToken->fresh()] as $token) {
+ $this->actingAs($this->user->withAccessToken($token));
+ auditLog('api.project.updated', ['team_id' => $this->team->id]);
+ }
+
+ $events = AuditEvent::query()->orderBy('id')->get();
+
+ expect($events->pluck('actor_token_id')->all())->toBe([
+ $firstToken->accessToken->id,
+ $secondToken->accessToken->id,
+ ])->and($events->pluck('actor_token_name')->all())->toBe([
+ 'first-token',
+ 'second-token',
+ ]);
+
+ Livewire::test(AuditLog::class)
+ ->assertSee('Token: first-token')
+ ->assertSee('Token: second-token');
+});
+
+test('API model mutations produce one audit event', function () {
+ $this->withoutExceptionHandling();
+ $token = $this->user->createToken('audit-api', ['root']);
+ $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ auth()->logout();
+ auth()->forgetGuards();
+
+ $response = $this->withToken($token->plainTextToken)->postJson('/api/v1/projects', [
+ 'name' => 'Single API audit event',
+ ]);
+
+ $response->assertCreated();
+
+ expect(AuditEvent::query()
+ ->where('event', 'api.project.created')
+ ->where('resource_uuid', $response->json('uuid'))
+ ->count())->toBe(1);
+});
+
+test('API application updates produce one audit event', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $application = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ]);
+ AuditEvent::query()->delete();
+
+ $token = $this->user->createToken('audit-api', ['root']);
+ $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ auth()->logout();
+ auth()->forgetGuards();
+
+ $this->withToken($token->plainTextToken)
+ ->patchJson("/api/v1/applications/{$application->uuid}", ['description' => 'Updated through API'])
+ ->assertOk();
+
+ expect(AuditEvent::query()
+ ->where('event', 'api.application.updated')
+ ->where('resource_uuid', $application->uuid)
+ ->count())->toBe(1);
+});
+
+test('deployment queue records rollback and cancellation operations', function () {
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $application = Application::factory()->create(['environment_id' => $environment->id]);
+ AuditEvent::query()->delete();
+
+ $deployment = ApplicationDeploymentQueue::query()->create([
+ 'application_id' => $application->id,
+ 'deployment_uuid' => 'rollback-deployment',
+ 'commit' => 'abc123',
+ 'rollback' => true,
+ 'status' => 'queued',
+ ]);
+
+ $deployment->update(['status' => 'cancelled-by-user']);
+
+ expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([
+ 'ui.application.rollback',
+ 'ui.deployment.cancelled',
+ ]);
+});
+
+test('team resource models opt in to automatic auditing', function (string $model) {
+ expect(class_uses_recursive($model))->toContain(Auditable::class);
+})->with([
+ Application::class,
+ Service::class,
+ Server::class,
+ Project::class,
+ Environment::class,
+ EnvironmentVariable::class,
+ SharedEnvironmentVariable::class,
+ PrivateKey::class,
+ StandalonePostgresql::class,
+ StandaloneMysql::class,
+ StandaloneMariadb::class,
+ StandaloneMongodb::class,
+ StandaloneRedis::class,
+ StandaloneKeydb::class,
+ StandaloneDragonfly::class,
+ StandaloneClickhouse::class,
+]);
+
+test('withoutAuditLogging suppresses mutations until the outer callback ends', function () {
+ $project = Project::factory()->create([
+ 'team_id' => $this->team->id,
+ 'name' => 'Original project',
+ 'description' => 'Original description',
+ ]);
+ AuditEvent::query()->delete();
+
+ $project->withoutAuditLogging(function () use ($project) {
+ $project->update(['name' => 'Outer suppressed']);
+
+ $project->withoutAuditLogging(function () use ($project) {
+ $project->update(['description' => 'Inner suppressed']);
+ });
+
+ $project->update(['name' => 'Still suppressed']);
+ });
+
+ expect(AuditEvent::query()->count())->toBe(0);
+
+ $project->update(['description' => 'Logged after suppression']);
+
+ expect(AuditEvent::query()->pluck('event')->all())->toBe(['ui.project.updated']);
+});
+
+test('status-only database updates do not record last online audit changes', function () {
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ foreach ([StandaloneClickhouse::class, StandaloneRedis::class] as $model) {
+ $attributes = [
+ 'uuid' => fake()->uuid(),
+ 'name' => 'Status test database',
+ 'status' => 'exited',
+ 'environment_id' => $environment->id,
+ 'destination_type' => Server::class,
+ 'destination_id' => 0,
+ ];
+ if ($model === StandaloneClickhouse::class) {
+ $attributes['clickhouse_admin_password'] = 'password';
+ }
+
+ $database = $model::create($attributes);
+ AuditEvent::query()->delete();
+
+ $database->update(['status' => 'running']);
+
+ expect(AuditEvent::query()->count())->toBe(0);
+
+ $database->update(['name' => 'Renamed status test database']);
+
+ $event = AuditEvent::query()->sole();
+
+ expect($event->metadata['changed_fields'])->toBe(['name']);
+ AuditEvent::query()->delete();
+ }
+});
+
+test('audit log redacts sensitive metadata', function () {
+ auditLog('api.application.updated', [
+ 'team_id' => $this->team->id,
+ 'application_uuid' => 'app-123',
+ 'token' => 'secret-token',
+ 'nested' => ['password' => 'secret-password', 'safe' => 'visible'],
+ ]);
+
+ $metadata = AuditEvent::query()->sole()->metadata;
+
+ expect($metadata['token'])->toBe('[REDACTED]')
+ ->and($metadata['nested']['password'])->toBe('[REDACTED]')
+ ->and($metadata['nested']['safe'])->toBe('visible');
+});
+
+test('audit log redacts common credential metadata keys', function (string $key) {
+ auditLog('api.application.updated', [
+ 'team_id' => $this->team->id,
+ $key => 'sensitive-value',
+ ]);
+
+ expect(AuditEvent::query()->sole()->metadata[$key])->toBe('[REDACTED]');
+})->with([
+ 'api_key',
+ 'access_key',
+ 'authorization',
+ 'cookie',
+ 'client_secret',
+]);
+
+test('audit event classification can use explicit resource and action context', function () {
+ auditLog('mcp.control', [
+ 'team_id' => $this->team->id,
+ 'resource' => 'application',
+ 'action' => 'restart',
+ 'resource_uuid' => 'app-123',
+ ]);
+
+ $event = AuditEvent::query()->sole();
+
+ expect($event->resource_type)->toBe('application')
+ ->and($event->action)->toBe('restart')
+ ->and($event->resource_uuid)->toBe('app-123');
+});
+
+test('team invitation audit logs redact the invitation email', function () {
+ auditLog('ui.team_invitation.created', [
+ 'team_id' => $this->team->id,
+ 'invitation_uuid' => 'invitation-123',
+ 'invitation_email' => 'invitee@example.com',
+ 'role' => 'member',
+ 'via' => 'email',
+ ]);
+
+ $metadata = AuditEvent::query()->sole()->metadata;
+
+ expect($metadata['invitation_email'])->toBe('[REDACTED]')
+ ->and($metadata['invitation_uuid'])->toBe('invitation-123')
+ ->and($metadata['role'])->toBe('member')
+ ->and($metadata['via'])->toBe('email');
+});
+
+test('audit log page only shows events for the current team', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'description' => 'Website created',
+ ]);
+ AuditEvent::factory()->create([
+ 'team_id' => Team::factory()->create()->id,
+ 'description' => 'Private app deleted',
+ ]);
+
+ Livewire::test(AuditLog::class)
+ ->assertSee('Website created')
+ ->assertDontSee('Private app deleted');
+});
+
+test('audit log is available under team settings', function () {
+ $this->get('/team/audit-log')
+ ->assertSuccessful()
+ ->assertSeeLivewire(AuditLog::class);
+});
+
+test('team members cannot view the audit log page', function () {
+ $member = User::factory()->create();
+ $this->team->members()->attach($member->id, ['role' => 'member']);
+
+ $this->actingAs($member);
+ session(['currentTeam' => $this->team]);
+
+ $this->get('/team/audit-log')->assertForbidden();
+});
+
+test('demoted team admins cannot make subsequent audit log requests', function () {
+ $component = Livewire::test(AuditLog::class);
+
+ $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
+ auth()->setUser($this->user->fresh());
+
+ $component->set('search', 'deployment')->assertStatus(403);
+});
+
+test('team admins can query only their team audit events through the api', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'event' => 'api.project.updated',
+ 'source' => 'api',
+ 'action' => 'updated',
+ 'description' => 'Visible event',
+ 'actor_email' => 'owner@example.com',
+ 'actor_token_name' => 'production token',
+ 'metadata' => ['changed_fields' => ['name']],
+ 'ip_address' => '192.0.2.1',
+ 'user_agent' => 'Sensitive user agent',
+ ]);
+ AuditEvent::factory()->create([
+ 'team_id' => Team::factory()->create()->id,
+ 'event' => 'api.project.updated',
+ 'source' => 'api',
+ 'action' => 'updated',
+ 'description' => 'Other team event',
+ ]);
+
+ $token = $this->user->createToken('audit-read', ['read']);
+ $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ auth()->logout();
+ auth()->forgetGuards();
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/audit-events?source=api&action=updated')
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.description', 'Visible event')
+ ->assertJsonMissingPath('data.0.actor_email')
+ ->assertJsonMissingPath('data.0.actor_token_id')
+ ->assertJsonMissingPath('data.0.actor_token_name')
+ ->assertJsonMissingPath('data.0.metadata')
+ ->assertJsonMissingPath('data.0.ip_address')
+ ->assertJsonMissingPath('data.0.user_agent');
+});
+
+test('team admins with sensitive read access can query full audit event details', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'actor_email' => 'owner@example.com',
+ 'actor_token_name' => 'production token',
+ 'metadata' => ['changed_fields' => ['name']],
+ 'ip_address' => '192.0.2.1',
+ 'user_agent' => 'Sensitive user agent',
+ ]);
+
+ $token = $this->user->createToken('audit-sensitive-read', ['read', 'read:sensitive']);
+ $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ auth()->logout();
+ auth()->forgetGuards();
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/audit-events')
+ ->assertOk()
+ ->assertJsonPath('data.0.actor_email', 'owner@example.com')
+ ->assertJsonPath('data.0.actor_token_name', 'production token')
+ ->assertJsonPath('data.0.metadata.changed_fields.0', 'name')
+ ->assertJsonPath('data.0.ip_address', '192.0.2.1')
+ ->assertJsonPath('data.0.user_agent', 'Sensitive user agent');
+});
+
+test('tokens without sensitive read access cannot search private audit fields', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'actor_email' => 'private-audit@example.com',
+ 'description' => 'Public description',
+ ]);
+
+ $token = $this->user->createToken('audit-read', ['read']);
+ $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ auth()->logout();
+ auth()->forgetGuards();
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/audit-events?search=private-audit@example.com')
+ ->assertOk()
+ ->assertJsonCount(0, 'data');
+});
+
+test('team members cannot query audit events through the api', function () {
+ $member = User::factory()->create();
+ $this->team->members()->attach($member->id, ['role' => 'member']);
+ $this->actingAs($member);
+ session(['currentTeam' => $this->team]);
+ $token = $member->createToken('audit-read', ['read']);
+ $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ auth()->logout();
+ auth()->forgetGuards();
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/audit-events')
+ ->assertForbidden();
+});
+
+test('audit events api rejects pagination and filter values outside the allowed bounds', function (string $query, string $field) {
+ $token = $this->user->createToken('audit-read', ['read']);
+ $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ auth()->logout();
+ auth()->forgetGuards();
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/audit-events?'.$query)
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors([$field]);
+})->with([
+ 'per_page below minimum' => ['per_page=0', 'per_page'],
+ 'per_page above maximum' => ['per_page=101', 'per_page'],
+ 'page below minimum' => ['page=0', 'page'],
+ 'unsupported source' => ['source=unknown', 'source'],
+ 'action longer than 255 characters' => ['action='.str_repeat('a', 256), 'action'],
+]);
+
+test('audit events api accepts valid pagination and filter values', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'event' => 'api.project.updated',
+ 'source' => 'api',
+ 'action' => 'updated',
+ 'description' => 'Visible event',
+ ]);
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'event' => 'ui.project.created',
+ 'source' => 'ui',
+ 'action' => 'created',
+ 'description' => 'Other event',
+ ]);
+
+ $token = $this->user->createToken('audit-read', ['read']);
+ $token->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ auth()->logout();
+ auth()->forgetGuards();
+
+ $this->withToken($token->plainTextToken)
+ ->getJson('/api/v1/audit-events?per_page=1&source=api&action=updated&search=Visible')
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.description', 'Visible event')
+ ->assertJsonPath('per_page', 1);
+});
+
+test('audit source filter omits the unused system source', function () {
+ Livewire::test(AuditLog::class)
+ ->assertSee('All sources')
+ ->assertSee('Web UI')
+ ->assertSee('API')
+ ->assertSee('MCP')
+ ->assertSee('Webhook')
+ ->assertDontSee('System');
+});
+
+test('audit action filter includes actions recorded for the current team', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'action' => 'backup_schedule_deleted',
+ ]);
+
+ Livewire::test(AuditLog::class)
+ ->assertViewHas('actionOptions', fn (array $options): bool => in_array([
+ 'value' => 'backup_schedule_deleted',
+ 'label' => 'Backup Schedule Deleted',
+ ], $options, true));
+});
+
+test('resource clone audit starts only after the destination server capability check', function () {
+ $source = file_get_contents(app_path('Livewire/Project/Shared/ResourceOperations.php'));
+
+ expect(strpos($source, "auditLog('ui.resource.clone_started'"))
+ ->toBeGreaterThan(strpos($source, 'if (! $server->canHostResources())'));
+});
+
+test('pull and restart records the service restart audit event after starting the service', function () {
+ $method = new ReflectionMethod(Heading::class, 'pullAndRestartEvent');
+ $source = file($method->getFileName());
+ $methodSource = implode('', array_slice($source, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
+
+ expect($methodSource)
+ ->toContain("auditServiceAction('ui.service.restarted')")
+ ->and(strpos($methodSource, 'StartService::run'))->toBeLessThan(strpos($methodSource, 'auditServiceAction'));
+});
+
+test('critical operational events persist with their source action and actor', function (string $event) {
+ auditLog($event, [
+ 'team_id' => $this->team->id,
+ 'resource_uuid' => 'resource-123',
+ 'resource_name' => 'Test resource',
+ ]);
+
+ $auditEvent = AuditEvent::query()->sole();
+
+ expect($auditEvent->event)->toBe($event)
+ ->and($auditEvent->source)->toBe(str($event)->before('.')->value())
+ ->and($auditEvent->action)->toBe(str($event)->afterLast('.')->value())
+ ->and($auditEvent->actor_email)->toBe($this->user->email);
+})->with([
+ 'ui.application.stopped',
+ 'ui.application.preview_stopped',
+ 'ui.application.destination_stopped',
+ 'ui.application.rollback',
+ 'ui.deployment.cancelled',
+ 'ui.service.started',
+ 'ui.service.stopped',
+ 'ui.service.restarted',
+ 'ui.database.started',
+ 'ui.database.stopped',
+ 'ui.database.restarted',
+ 'ui.proxy.stopped',
+ 'ui.proxy.restarted',
+ 'ui.database.backup_started',
+ 'ui.database.backup_schedule_deleted',
+ 'ui.database.import_started',
+ 'ui.database.restore_started',
+ 'ui.scheduled_task.executed',
+ 'ui.api_token.created',
+ 'ui.api_token.revoked',
+ 'ui.team_member.role_updated',
+ 'ui.team_member.removed',
+ 'ui.team_invitation.created',
+ 'ui.team_invitation.revoked',
+ 'ui.server.docker_cleanup_started',
+ 'ui.server.imported',
+ 'ui.project.clone_started',
+ 'ui.resource.clone_started',
+ 'api.database.started',
+ 'api.database.stopped',
+ 'api.database.restarted',
+ 'api.database.import_started',
+]);
+
+test('audit log uses the standard horizontally scrollable table layout on mobile', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'actor_name' => 'Visible Actor',
+ 'actor_token_name' => 'visible-audit-token',
+ ]);
+
+ Livewire::test(AuditLog::class)
+ ->assertSeeHtml('class="overflow-x-auto"')
+ ->assertSeeHtml('class="data-table transition-opacity"')
+ ->assertSeeHtml('class="grid min-w-[760px] grid-cols-[14rem_minmax(0,1fr)_12rem_9rem]')
+ ->assertSeeHtml('class="self-center text-right text-[11px]')
+ ->assertSeeHtml('title="Token: visible-audit-token"')
+ ->assertSee('Actor')
+ ->assertSee('Visible Actor');
+});
+
+test('audit log displays source abbreviations in uppercase', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'source' => 'cli',
+ ]);
+
+ Livewire::test(AuditLog::class)
+ ->assertSee('CLI');
+});
+
+test('audit log page filters events by search and action', function () {
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'action' => 'created',
+ 'description' => 'Website created',
+ 'resource_name' => 'Website',
+ ]);
+ AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'event' => 'api.server.deleted',
+ 'action' => 'deleted',
+ 'description' => 'Build server deleted',
+ 'resource_name' => 'Build server',
+ ]);
+
+ Livewire::test(AuditLog::class)
+ ->set('search', 'Website')
+ ->assertSee('Website created')
+ ->assertDontSee('Build server deleted')
+ ->set('search', '')
+ ->set('action', 'deleted')
+ ->assertDontSee('Website created')
+ ->assertSee('Build server deleted');
+});
+
+test('updating team settings records an audit event', function () {
+ Livewire::test(TeamIndex::class)
+ ->set('name', 'Renamed team')
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $event = AuditEvent::query()->where('action', 'updated')->sole();
+
+ expect($event->event)->toBe('ui.team.updated')
+ ->and($event->team_id)->toBe($this->team->id)
+ ->and($event->resource_name)->toBe('Renamed team');
+});
+
+test('updating an environment variable records an event without its value', function () {
+ $variable = SharedEnvironmentVariable::create([
+ 'team_id' => $this->team->id,
+ 'type' => 'team',
+ 'key' => 'API_SECRET',
+ 'value' => 'old-secret',
+ ]);
+
+ Livewire::test(Show::class, [
+ 'env' => $variable,
+ 'type' => 'team',
+ ])
+ ->call('loadValues')
+ ->set('value', 'new-secret')
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $event = AuditEvent::query()
+ ->where('resource_type', 'shared_environment_variable')
+ ->where('action', 'updated')
+ ->sole();
+
+ expect($event->event)->toBe('ui.shared_environment_variable.updated')
+ ->and($event->resource_name)->toBe('API_SECRET')
+ ->and(json_encode($event->metadata))->not->toContain('new-secret');
+});
+
+test('creating an application environment variable records an audit event', function () {
+ $this->withDefer();
+
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $application = Application::factory()->create(['environment_id' => $environment->id]);
+
+ $application->environment_variables()->create([
+ 'key' => 'API_SECRET',
+ 'value' => 'secret-value',
+ ]);
+
+ defer()->invoke();
+
+ $event = AuditEvent::query()
+ ->where('resource_type', 'environment_variable')
+ ->where('action', 'created')
+ ->where('resource_name', 'API_SECRET')
+ ->firstOrFail();
+
+ expect($event->team_id)->toBe($this->team->id)
+ ->and($event->resource_name)->toBe('API_SECRET')
+ ->and(json_encode($event->metadata))->not->toContain('secret-value');
+});
+
+test('database cleanup removes audit events older than 90 days', function () {
+ $old = AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'created_at' => now()->subDays(91),
+ ]);
+ $recent = AuditEvent::factory()->create([
+ 'team_id' => $this->team->id,
+ 'created_at' => now()->subDays(89),
+ ]);
+
+ AuditEvent::pruneExpired();
+
+ expect($old->fresh())->toBeNull()
+ ->and($recent->fresh())->not->toBeNull();
+});
diff --git a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php
index eb331a775f..93c9658a7b 100644
--- a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php
+++ b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php
@@ -247,6 +247,18 @@ test('member cannot submit application advanced settings', function () {
->assertDispatched('error');
});
+test('member cannot save the application container name prefix', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(ApplicationAdvanced::class, ['application' => $this->application])
+ ->set('customContainerNamePrefix', 'member-prefix')
+ ->call('saveCustomNamePrefix')
+ ->assertDispatched('error');
+
+ expect($this->application->settings->fresh()->custom_container_name_prefix)->toBeNull();
+});
+
test('the private application advanced syncData helper is not remotely callable', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
diff --git a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php
index c415ff3242..ec78d0f30d 100644
--- a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php
+++ b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php
@@ -18,7 +18,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
- InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
+ InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
@@ -103,6 +103,11 @@ test('admin sees unlocked env value in Show component', function () {
'type' => 'application',
]);
+ // Values hydrate lazily; the edit modal triggers loadValues() on open.
+ expect($component->get('value'))->toBeNull();
+
+ $component->call('loadValues');
+
expect($component->get('value'))->toBe('secret-unlocked-value');
});
@@ -246,6 +251,17 @@ test('API hides env values for member even with read:sensitive token', function
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
+ $response->assertForbidden();
+});
+
+test('API hides env values for member with read token', function () {
+ session(['currentTeam' => $this->team]);
+ $token = $this->member->createToken('member-read', ['read']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ ])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
+
$response->assertOk();
$envs = collect($response->json());
diff --git a/tests/Feature/ButtonDepthInteractionTest.php b/tests/Feature/ButtonDepthInteractionTest.php
index b2076f4fe4..9e1e435a84 100644
--- a/tests/Feature/ButtonDepthInteractionTest.php
+++ b/tests/Feature/ButtonDepthInteractionTest.php
@@ -7,8 +7,7 @@ test('standard buttons use raised hover and pressed depth states', function () {
->toContain('--button-depth-color: rgb(0 0 0 / 0.22);')
->toContain('--button-depth: 0 2px 0 var(--button-depth-color);')
->toContain('--button-depth-hover: 0 3px 0 var(--button-depth-color);')
- ->toContain('.button-highlighted:not(:disabled)')
- ->toContain('--button-depth-color: color-mix(in oklab, var(--color-coollabs) 52%, black);')
+ ->toContain('--button-depth-color: var(--color-coollabs-300);')
->toContain('.dark .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled)')
->not->toContain('.dark .button:not(.button-highlighted):not([isHighlighted]):not(:disabled)')
->toContain('--button-depth-color: rgb(255 255 255 / 0.08);')
diff --git a/tests/Feature/CopyButtonComponentTest.php b/tests/Feature/CopyButtonComponentTest.php
index a9996a062e..7177da08e8 100644
--- a/tests/Feature/CopyButtonComponentTest.php
+++ b/tests/Feature/CopyButtonComponentTest.php
@@ -1,16 +1,42 @@
blade(' ');
$html->assertSee('Copy backup path')
->assertSee('backup\/path.sql', false)
- ->assertSee('window.copyToClipboard', false)
- ->assertSee('size-6', false);
+ ->assertSee('x-data="copyButton"', false)
+ ->assertDontSee('window.copyToClipboard', false);
});
-it('uses the reusable copy button for database backup paths', function () {
- $view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
+it('disables the button when no backend value is available', function () {
+ $html = $this->blade(' ');
- expect($view)->toContain(' ');
+ $html->assertSee('disabled', false);
+});
+
+it('evaluates a resolve expression at click time instead of a static value', function () {
+ $html = $this->blade(' ');
+
+ $html->assertSee('await ($wire.copyValue())', false)
+ ->assertDontSee('disabled', false);
+});
+
+it('is the single clipboard implementation shared by its call sites', function () {
+ expect(file_get_contents(resource_path('js/copy-button.js')))
+ ->toContain("window.Alpine.data('copyButton'");
+
+ expect(file_get_contents(resource_path('js/app.js')))
+ ->toContain('initializeCopyButtonComponent');
+
+ $modalConfirmation = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
+ $backupExecutions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
+
+ expect($modalConfirmation)
+ ->toContain('not->toContain('navigator.clipboard');
+
+ expect($backupExecutions)
+ ->toContain('not->toContain('navigator.clipboard');
});
diff --git a/tests/Feature/DashboardServerMetricsChartTest.php b/tests/Feature/DashboardServerMetricsChartTest.php
index 4d7c64d0fd..c1208106b7 100644
--- a/tests/Feature/DashboardServerMetricsChartTest.php
+++ b/tests/Feature/DashboardServerMetricsChartTest.php
@@ -79,7 +79,8 @@ it('configures the dashboard chart as a ten minute cpu and memory sparkline with
->not->toContain('w-full overflow-hidden rounded-b-xl')
->toContain('CPU:')
->toContain('Memory:')
- ->toContain('formatTimestamp(timestamp)')
+ ->toContain('formatLocalTimestamp(timestamp)')
+ ->toContain('formatUtcTimestamp(timestamp)')
->toContain("min: 0,\n max: 100,")
->toContain('labels: { show: false }');
});
diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php
index f8416fe6c7..82833747bc 100644
--- a/tests/Feature/DatabaseBackupUploadValidationTest.php
+++ b/tests/Feature/DatabaseBackupUploadValidationTest.php
@@ -1,9 +1,9 @@
invoke(null, $name);
}
-function backupValidationImportFormWithResource(string $modelClass): ImportForm
+function postgresScanScript(string $path): ?string
{
- $component = new class extends ImportForm
- {
- public $resource;
- };
+ $database = Mockery::mock(StandalonePostgresql::class);
+ $database->shouldReceive('getMorphClass')->andReturn(StandalonePostgresql::class);
- $database = Mockery::mock($modelClass);
- $database->shouldReceive('getMorphClass')->andReturn($modelClass);
- $component->resource = $database;
-
- return $component;
+ return (new DatabaseImportCommandBuilder)->buildPostgresRestoreScanScript($database, $path);
}
function makeTemporaryUpload(string $name, string $content): UploadedFile
@@ -173,39 +167,21 @@ SQL;
});
test('postgresql restore commands include a safety check before execution', function () {
- $component = new class extends ImportForm
- {
- public function __get($property)
- {
- if ($property === 'resource') {
- return new class
- {
- public function getMorphClass(): string
- {
- return StandalonePostgresql::class;
- }
- };
- }
+ $database = Mockery::mock(StandalonePostgresql::class);
+ $database->shouldReceive('getMorphClass')->andReturn(StandalonePostgresql::class);
- return parent::__get($property);
- }
- };
- $component->container = 'postgres-test';
-
- $command = $component->buildRestoreSafetyCheckCommand('/tmp/restore_test');
+ $command = (new DatabaseImportCommandBuilder)->buildPostgresSafetyCommand(
+ $database,
+ 'postgres-test',
+ '/tmp/restore_test',
+ );
expect($command)
->toContain('docker exec postgres-test')
->toContain('COPY ... PROGRAM')
->toContain('/tmp/restore_test')
- ->toContain('grep -Eiq');
-});
-
-test('non postgresql restore commands do not include a safety check', function () {
- $component = backupValidationImportFormWithResource('App\Models\StandaloneMysql');
- $component->container = 'mysql-test';
-
- expect($component->buildRestoreSafetyCheckCommand('/tmp/restore_test'))->toBeNull();
+ ->toContain('grep -Eiq')
+ ->toContain('pg_restore -l');
});
test('file scanner detects program execution payloads inside gzipped backups', function () {
@@ -251,11 +227,8 @@ test('backup validator rejects plaintext .dump containing program execution', fu
});
test('remote postgresql scanner blocks bypass payloads', function (string $content, bool $gzip) {
- $component = backupValidationImportFormWithResource(StandalonePostgresql::class);
- $component->container = 'postgres-test';
-
$payload = writeScanPayload($content, $gzip);
- $script = $component->buildPostgresRestoreScanScript($payload);
+ $script = postgresScanScript($payload);
expect(scannerBlocks($script))->toBeTrue();
})->with([
@@ -272,11 +245,8 @@ test('remote postgresql scanner blocks bypass payloads', function (string $conte
]);
test('remote postgresql scanner allows legitimate restores', function (string $content, bool $gzip) {
- $component = backupValidationImportFormWithResource(StandalonePostgresql::class);
- $component->container = 'postgres-test';
-
$payload = writeScanPayload($content, $gzip);
- $script = $component->buildPostgresRestoreScanScript($payload);
+ $script = postgresScanScript($payload);
expect(scannerBlocks($script))->toBeFalse();
})->with([
@@ -288,7 +258,6 @@ test('remote postgresql scanner allows legitimate restores', function (string $c
]);
test('remote postgresql scanner inspects custom archives instead of skipping them', function () {
- $component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$safeArchive = writeScanPayload("PGDMP\0binary archive");
$maliciousSql = "COPY x FROM PROGRAM 'id';\n";
$safeSql = "CREATE TABLE users (id integer);\nCOPY users FROM stdin;\n1\tTaylor\n\\.\n";
@@ -298,10 +267,10 @@ test('remote postgresql scanner inspects custom archives instead of skipping the
$unreadablePath = fakePgRestorePath($safeSql, listExitCode: 1);
$path = getenv('PATH') ?: '/usr/bin:/bin';
- expect(scannerBlocks($component->buildPostgresRestoreScanScript($safeArchive), ['PATH' => $maliciousPath.':'.$path]))->toBeTrue()
- ->and(scannerBlocks($component->buildPostgresRestoreScanScript($safeArchive), ['PATH' => $safePath.':'.$path]))->toBeFalse()
- ->and(scannerBlocks($component->buildPostgresRestoreScanScript($safeArchive), ['PATH' => $unreadablePath.':'.$path]))->toBeTrue()
- ->and(scannerBlocks($component->buildPostgresRestoreScanScript($safeArchive)))->toBeTrue();
+ expect(scannerBlocks(postgresScanScript($safeArchive), ['PATH' => $maliciousPath.':'.$path]))->toBeTrue()
+ ->and(scannerBlocks(postgresScanScript($safeArchive), ['PATH' => $safePath.':'.$path]))->toBeFalse()
+ ->and(scannerBlocks(postgresScanScript($safeArchive), ['PATH' => $unreadablePath.':'.$path]))->toBeTrue()
+ ->and(scannerBlocks(postgresScanScript($safeArchive)))->toBeTrue();
});
test('MAX_BYTES constant is 10 GiB', function () {
diff --git a/tests/Feature/DatabaseImportFormRunningStateTest.php b/tests/Feature/DatabaseImportFormRunningStateTest.php
new file mode 100644
index 0000000000..a355be7782
--- /dev/null
+++ b/tests/Feature/DatabaseImportFormRunningStateTest.php
@@ -0,0 +1,132 @@
+ 0,
+ 'disable_two_step_confirmation' => true,
+ ]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::firstOrCreate(
+ ['server_id' => $this->server->id, 'network' => 'coolify'],
+ ['uuid' => (string) Str::uuid(), 'name' => 'docker']
+ );
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+ $this->database = StandalonePostgresql::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'db',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'db',
+ 'image' => 'postgres:17',
+ 'status' => 'running',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+});
+
+class ImportFormRunningStateTestComponent extends ImportForm
+{
+ public function mount($database = null, $server = null): void
+ {
+ $this->resourceId = $database->id;
+ $this->resourceType = $database::class;
+ $this->serverId = $server->id;
+ $this->container = $database->uuid;
+ $this->resourceUuid = $database->uuid;
+ $this->resourceStatus = $database->status ?? '';
+ $this->resourceDbType = $database->type();
+ $this->loadAvailableS3Storages();
+ }
+
+ public function render()
+ {
+ return view('livewire.project.database.import-form');
+ }
+}
+
+function importForm(): Testable
+{
+ return Livewire::test(ImportFormRunningStateTestComponent::class, [
+ 'database' => test()->database,
+ 'server' => test()->server,
+ ])
+ ->set('filename', 'backup.dump')
+ ->set('s3StorageId', 1)
+ ->set('s3Path', '/backups/backup.dump')
+ ->set('s3FileSize', 1024);
+}
+
+test('runImport resets importRunning when StartDatabaseImport rejects the import', function () {
+ StartDatabaseImport::shouldRun()->once()->andThrow(
+ new DatabaseImportException('The completed upload was not found.')
+ );
+
+ importForm()
+ ->call('runImport')
+ ->assertSet('importRunning', false)
+ ->assertDispatched('error', 'The completed upload was not found.');
+});
+
+test('runImport resets importRunning when StartDatabaseImport throws a generic error', function () {
+ StartDatabaseImport::shouldRun()->once()->andThrow(new RuntimeException('scp failed'));
+
+ importForm()
+ ->call('runImport')
+ ->assertSet('importRunning', false)
+ ->assertDispatched('error', 'scp failed');
+});
+
+test('restoreFromS3 resets importRunning when StartDatabaseImport rejects the import', function () {
+ StartDatabaseImport::shouldRun()->once()->andThrow(
+ new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.')
+ );
+
+ importForm()
+ ->call('restoreFromS3')
+ ->assertSet('importRunning', false)
+ ->assertDispatched('error', 'The S3 backup was not found or exceeds the 10 GiB limit.');
+});
+
+test('runImport keeps importRunning true after a successful start', function () {
+ $activity = Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'queued',
+ 'properties' => ['status' => 'queued'],
+ ]);
+ StartDatabaseImport::shouldRun()->once()->andReturn($activity);
+
+ importForm()
+ ->call('runImport')
+ ->assertSet('importRunning', true)
+ ->assertSet('activityId', $activity->id)
+ ->assertDispatched('activityMonitor', $activity->id)
+ ->assertDispatched('databaserestore');
+});
diff --git a/tests/Feature/DatabaseRestoreDialogTest.php b/tests/Feature/DatabaseRestoreDialogTest.php
index 5f78706a8c..f373afe6e2 100644
--- a/tests/Feature/DatabaseRestoreDialogTest.php
+++ b/tests/Feature/DatabaseRestoreDialogTest.php
@@ -18,3 +18,22 @@ test('postgresql dump all restore warns that administrator passwords are overwri
->toContain('The backup replaces PostgreSQL administrator role passwords, including the destination administrator password.')
->toContain("If the administrator password changes, update it in Coolify's database configuration after the restore.");
});
+
+test('postgresql single database restore offers explicit object replacement', function () {
+ $view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
+
+ expect($view)
+ ->toContain('Replace objects that already exist')
+ ->toContain('wire:model="postgresqlRestoreCommand"')
+ ->toContain('label="Import command" readonly');
+});
+
+test('restore confirmation dialogs warn that existing objects can block the import', function () {
+ $view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
+
+ expect($view)
+ ->toContain('submitAction="runImport"')
+ ->toContain('submitAction="restoreFromS3"')
+ ->toContain('Existing objects can cause the import to fail unless replacement is enabled.')
+ ->not->toContain('All existing data will be replaced.');
+});
diff --git a/tests/Feature/DatabaseStartActivityLoggingTest.php b/tests/Feature/DatabaseStartActivityLoggingTest.php
new file mode 100644
index 0000000000..ff8f0e8ec8
--- /dev/null
+++ b/tests/Feature/DatabaseStartActivityLoggingTest.php
@@ -0,0 +1,34 @@
+set('activitylog.enabled', false);
+ app(ActivityLogStatus::class)->disable();
+ Bus::fake();
+
+ $server = new Server(['ip' => '192.0.2.1']);
+ $server->setRelation('settings', new ServerSetting([
+ 'is_reachable' => true,
+ 'is_usable' => true,
+ 'force_disabled' => false,
+ ]));
+
+ $destination = new StandaloneDocker;
+ $destination->setRelation('server', $server);
+
+ $database = new StandaloneRedis;
+ $database->setRelation('destination', $destination);
+
+ $result = (new StartDatabase)->handle($database);
+
+ expect($result)->toBe('Database start could not be queued because activity logging is disabled.');
+ Bus::assertNotDispatched(DatabaseStartJob::class);
+});
diff --git a/tests/Feature/DeploymentLogsLayoutTest.php b/tests/Feature/DeploymentLogsLayoutTest.php
index be825c663b..f340c98f5f 100644
--- a/tests/Feature/DeploymentLogsLayoutTest.php
+++ b/tests/Feature/DeploymentLogsLayoutTest.php
@@ -118,6 +118,17 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
->toContain('logs-viewer-lines')
->toContain('logs-viewer-actions')
->toContain('logs-viewer-line')
+ ->toContain('runtime-log-columns')
+ ->toContain('runtime-log-detail')
+ ->toContain('runtime-log-empty')
+ ->toContain('runtime-log-loading')
+ ->toContain('wire:loading.flex wire:target="getLogs"')
+ ->toContain('wire:loading.remove wire:target="getLogs"')
+ ->toContain('Loading logs')
+ ->toContain('Logs will appear here when the container produces output.')
+ ->toContain('toggleLogDetails')
+ ->toContain('formatLogDetails')
+ ->toContain('aria-expanded')
->toContain('pl-8!')
->toContain('z-10 size-3.5')
->and($deploymentView)
@@ -131,6 +142,13 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
->toContain('.logs-viewer-actions')
->toContain('.logs-viewer-deployment-actions')
->toContain('.logs-settings-section')
+ ->toContain('.runtime-log-columns')
+ ->toContain('.runtime-log-detail')
+ ->toContain('.runtime-log-empty')
+ ->toContain('.runtime-log-loading')
+ ->toContain('grid-template-columns: var(--runtime-log-columns)')
+ ->toContain(".dark .runtime-log-columns {\n background: var(--coollabs-elevated);")
+ ->toContain("html[data-theme=\"custom\"] .runtime-log-columns {\n background: var(--color-log-toolbar);")
->toContain('padding: 0.5rem 0.75rem 0;')
->toContain('padding: 0.5rem 1rem 0;')
->toContain(".logs-viewer-viewport::after {\n content: \"\";\n flex: 0 0 2rem;")
diff --git a/tests/Feature/DnsProviderManagementTest.php b/tests/Feature/DnsProviderManagementTest.php
new file mode 100644
index 0000000000..1b1bfb0734
--- /dev/null
+++ b/tests/Feature/DnsProviderManagementTest.php
@@ -0,0 +1,684 @@
+create(['metadata' => null]);
+ $manualToken = IntegrationToken::factory()->create(['metadata' => ['automatic_dns' => false]]);
+
+ expect($defaultToken->automaticDnsEnabled())->toBeTrue()
+ ->and($manualToken->automaticDnsEnabled())->toBeFalse();
+});
+
+test('cloudflare zones are discovered and cached for a token', function () {
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret', 'capabilities' => ['dns']]);
+
+ Http::fake(['https://api.cloudflare.com/client/v4/zones*' => Http::response([
+ 'success' => true,
+ 'result' => [
+ ['id' => 'zone-1', 'name' => 'example.com', 'account' => ['id' => 'account-1', 'name' => 'Production']],
+ ['id' => 'zone-2', 'name' => 'example.org', 'account' => ['id' => 'account-1', 'name' => 'Production']],
+ ],
+ 'result_info' => ['page' => 1, 'total_pages' => 1],
+ ])]);
+
+ app(CloudflareDnsProvider::class)->syncZones($token);
+
+ expect($token->dnsZones()->orderBy('name')->pluck('name')->all())->toBe(['example.com', 'example.org'])
+ ->and($token->fresh()->metadata['zones_synced_at'])->not->toBeNull();
+});
+
+test('all credentials for the most specific zone are returned without suffix false positives', function () {
+ $team = Team::factory()->create();
+ $firstToken = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']);
+ $secondToken = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']);
+ DnsProviderZone::factory()->for($firstToken)->create(['name' => 'example.com']);
+ DnsProviderZone::factory()->for($secondToken)->create(['name' => 'example.com']);
+ DnsProviderZone::factory()->for($firstToken)->create(['name' => 'customer.example.com']);
+
+ $provider = app(CloudflareDnsProvider::class);
+
+ expect($provider->findZones($team->id, 'api.customer.example.com'))->toHaveCount(1)
+ ->and($provider->findZones($team->id, 'app.example.com'))->toHaveCount(2)
+ ->and($provider->findZones($team->id, 'notexample.com'))->toBeEmpty();
+});
+
+test('team cloudflare zones are queried once per provider instance', function () {
+ $team = Team::factory()->create();
+ $otherTeam = Team::factory()->create();
+ $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare']);
+ $otherToken = IntegrationToken::factory()->for($otherTeam)->create(['provider' => 'cloudflare']);
+ DnsProviderZone::factory()->for($token)->create(['name' => 'example.com']);
+ DnsProviderZone::factory()->for($otherToken)->create(['name' => 'other.com']);
+
+ $provider = app(CloudflareDnsProvider::class);
+
+ DB::flushQueryLog();
+ DB::enableQueryLog();
+ expect($provider->findZones($team->id, 'app.example.com'))->toHaveCount(1);
+ $firstCallQueries = count(DB::getQueryLog());
+
+ expect($provider->findZones($team->id, 'api.example.com'))->toHaveCount(1)
+ ->and($provider->findZones($team->id, 'www.example.com'))->toHaveCount(1)
+ ->and(count(DB::getQueryLog()))->toBe($firstCallQueries)
+ ->and($firstCallQueries)->toBeGreaterThan(0);
+
+ $afterTeamQueries = count(DB::getQueryLog());
+ expect($provider->findZones($otherTeam->id, 'app.other.com'))->toHaveCount(1)
+ ->and(count(DB::getQueryLog()))->toBeGreaterThan($afterTeamQueries);
+ DB::disableQueryLog();
+});
+
+test('a cloudflare record is created and tracked as managed by coolify', function () {
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+ $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
+
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]),
+ ]);
+
+ $record = app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10');
+
+ expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10');
+ Http::assertSent(fn ($request) => $request->method() === 'POST'
+ && $request->data()['name'] === 'app.example.com'
+ && $request->data()['content'] === '203.0.113.10');
+});
+
+test('queued dns configuration creates the record and broadcasts completion', function () {
+ Event::fake([DnsRecordConfigurationFinished::class]);
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+ $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]),
+ ]);
+
+ $job = new ConfigureDnsRecordJob(
+ teamId: $token->team_id,
+ zoneId: $zone->id,
+ resourceType: null,
+ resourceId: null,
+ hostname: 'app.example.com',
+ content: '203.0.113.10',
+ );
+ $job->handle(app(CloudflareDnsProvider::class));
+
+ expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue();
+ Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful
+ && $event->hostname === 'app.example.com');
+});
+
+test('missing zone throws from handle without broadcasting completion', function () {
+ Event::fake([DnsRecordConfigurationFinished::class]);
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+
+ $job = new ConfigureDnsRecordJob(
+ teamId: $token->team_id,
+ zoneId: 999999,
+ resourceType: null,
+ resourceId: null,
+ hostname: 'app.example.com',
+ content: '203.0.113.10',
+ );
+
+ expect(fn () => $job->handle(app(CloudflareDnsProvider::class)))
+ ->toThrow(ModelNotFoundException::class);
+
+ Event::assertNotDispatched(DnsRecordConfigurationFinished::class);
+});
+
+test('job failure broadcasts a dns configuration finished event', function () {
+ Event::fake([DnsRecordConfigurationFinished::class]);
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+
+ $job = new ConfigureDnsRecordJob(
+ teamId: $token->team_id,
+ zoneId: 999999,
+ resourceType: 'App\\Models\\Application',
+ resourceId: 42,
+ hostname: 'app.example.com',
+ content: '203.0.113.10',
+ );
+ $job->failed(new ModelNotFoundException);
+
+ Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful === false
+ && $event->teamId === $token->team_id
+ && $event->resourceType === 'App\\Models\\Application'
+ && $event->resourceId === 42
+ && $event->hostname === 'app.example.com'
+ && $event->credential === ''
+ && $event->message === 'The DNS zone is no longer available.');
+});
+
+test('an existing matching remote record is tracked without creating a new one', function () {
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+ $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
+
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([
+ 'success' => true,
+ 'result' => [[
+ 'id' => 'record-1',
+ 'type' => 'A',
+ 'name' => 'app.example.com',
+ 'content' => '203.0.113.10',
+ ]],
+ ]),
+ ]);
+
+ $record = app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10');
+
+ expect($record->provider_record_id)->toBe('record-1')
+ ->and($record->content)->toBe('203.0.113.10')
+ ->and(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue();
+ Http::assertNotSent(fn ($request) => $request->method() === 'POST');
+});
+
+test('queued dns configuration treats a matching existing record as success', function () {
+ Event::fake([DnsRecordConfigurationFinished::class]);
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+ $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([
+ 'success' => true,
+ 'result' => [[
+ 'id' => 'record-1',
+ 'type' => 'A',
+ 'name' => 'app.example.com',
+ 'content' => '203.0.113.10',
+ ]],
+ ]),
+ ]);
+
+ $job = new ConfigureDnsRecordJob(
+ teamId: $token->team_id,
+ zoneId: $zone->id,
+ resourceType: null,
+ resourceId: null,
+ hostname: 'app.example.com',
+ content: '203.0.113.10',
+ );
+ $job->handle(app(CloudflareDnsProvider::class));
+
+ expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue();
+ Event::assertDispatched(DnsRecordConfigurationFinished::class, fn ($event) => $event->successful
+ && $event->hostname === 'app.example.com');
+ Http::assertNotSent(fn ($request) => $request->method() === 'POST');
+});
+
+test('an existing remote record with different content remains a conflict', function () {
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+ $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
+
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([
+ 'success' => true,
+ 'result' => [[
+ 'id' => 'record-1',
+ 'type' => 'A',
+ 'name' => 'app.example.com',
+ 'content' => '203.0.113.99',
+ ]],
+ ]),
+ ]);
+
+ expect(fn () => app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10'))
+ ->toThrow(DnsRecordConflictException::class);
+ expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeFalse();
+ Http::assertNotSent(fn ($request) => $request->method() === 'POST');
+});
+
+test('a managed record changed outside coolify is not deleted', function () {
+ $record = ManagedDnsRecord::factory()->create([
+ 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
+ ]);
+
+ Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::response([
+ 'success' => true,
+ 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.99'],
+ ])]);
+
+ expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeFalse()->and($record->fresh())->not->toBeNull();
+});
+
+test('an unchanged managed record is deleted from cloudflare and coolify', function () {
+ $record = ManagedDnsRecord::factory()->create([
+ 'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
+ ]);
+
+ Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::sequence()
+ ->push(['success' => true, 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10']])
+ ->push(['success' => true, 'result' => ['id' => 'record-1']])]);
+
+ expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeTrue()
+ ->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
+});
+
+test('dns provider modal view always has a single root element', function () {
+ $providerModal = file_get_contents(resource_path('views/livewire/project/shared/dns-provider-management.blade.php'));
+
+ expect(ltrim($providerModal))->toStartWith('')
+ ->and($providerModal)->toContain('@if ($showDnsProviderModal)');
+});
+
+describe('domain DNS configuration after add', function () {
+ beforeEach(function () {
+ $this->withoutVite();
+ config()->set('app.maintenance.store', 'array');
+ Queue::fake();
+
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['id' => 0, 'is_dns_validation_enabled' => false],
+ ));
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $keyId = DB::table('private_keys')->insertGetId([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'Test Key',
+ 'private_key' => 'test-key',
+ 'team_id' => $this->team->id,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $keyId,
+ 'ip' => '203.0.113.10',
+ ]);
+ $this->server->settings()->update([
+ 'is_reachable' => true,
+ 'is_usable' => true,
+ ]);
+
+ StandaloneDocker::withoutEvents(function () {
+ $this->destination = StandaloneDocker::firstOrCreate(
+ ['server_id' => $this->server->id, 'network' => 'coolify'],
+ ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'],
+ );
+ });
+
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+
+ $this->token = IntegrationToken::factory()->for($this->team)->create([
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'secret',
+ 'capabilities' => ['dns'],
+ ]);
+ $this->zone = DnsProviderZone::factory()->for($this->token)->create([
+ 'name' => 'example.com',
+ 'provider_zone_id' => 'zone-1',
+ ]);
+ });
+
+ test('application domains queues a dns record job and marks the row pending', function () {
+ $application = Application::factory()->create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'DNS App',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'fqdn' => null,
+ 'redirect' => 'both',
+ 'build_pack' => 'nixpacks',
+ ]);
+ $application->settings()->update([
+ 'is_container_label_readonly_enabled' => true,
+ ]);
+
+ Livewire::test(Domains::class, ['application' => $application->fresh()])
+ ->set('newDomain', 'https://app.example.com')
+ ->call('addDomain')
+ ->assertHasNoErrors()
+ ->assertDispatched('success', 'Domain added.')
+ ->assertDispatched('info', 'Adding DNS record for app.example.com.')
+ ->assertSet('showDnsProviderModal', false)
+ ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains(
+ fn (array $row): bool => $row['url'] === 'https://app.example.com'
+ && $row['dns_status'] === 'pending'
+ ))
+ ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains(
+ fn (array $row): bool => $row['url'] === 'https://www.app.example.com'
+ && $row['dns_status'] === 'pending'
+ ));
+
+ Queue::assertPushed(ConfigureDnsRecordJob::class, 2);
+ Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'app.example.com'
+ && $job->content === '203.0.113.10'
+ && $job->teamId === $this->team->id
+ && $job->zoneId === $this->zone->id
+ && $job->resourceType === $application->getMorphClass()
+ && (string) $job->resourceId === (string) $application->id);
+ Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'www.app.example.com'
+ && $job->content === '203.0.113.10');
+ Queue::assertNotPushed(CheckDomainDnsJob::class);
+ });
+
+ test('service domains queues a dns record job and marks the row pending', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
+ ]);
+ $webApp = ServiceApplication::create([
+ 'uuid' => (string) Str::uuid(),
+ 'service_id' => $service->id,
+ 'name' => 'web',
+ 'human_name' => 'Web',
+ 'image' => 'nginx:alpine',
+ 'fqdn' => null,
+ ]);
+
+ Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])])
+ ->set('newServiceApplicationId', $webApp->id)
+ ->set('newDomain', 'https://web.example.com')
+ ->call('addDomain')
+ ->assertHasNoErrors()
+ ->assertDispatched('success', 'Domain added.')
+ ->assertDispatched('info', 'Adding DNS record for web.example.com.')
+ ->assertSet('showDnsProviderModal', false)
+ ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->contains(
+ fn (array $row): bool => $row['url'] === 'https://web.example.com'
+ && $row['dns_status'] === 'pending'
+ ));
+
+ Queue::assertPushed(ConfigureDnsRecordJob::class, 1);
+ Queue::assertPushed(ConfigureDnsRecordJob::class, fn (ConfigureDnsRecordJob $job): bool => $job->hostname === 'web.example.com'
+ && $job->content === '203.0.113.10'
+ && $job->teamId === $this->team->id
+ && $job->zoneId === $this->zone->id
+ && $job->resourceType === $webApp->getMorphClass()
+ && (string) $job->resourceId === (string) $webApp->id);
+ Queue::assertNotPushed(CheckDomainDnsJob::class);
+ });
+});
+
+test('dns provider action controls declare update authorization against the resource', function () {
+ $dnsMenu = file_get_contents(resource_path('views/livewire/project/shared/cloudflare-autoconfigure.blade.php'));
+ $providerModal = file_get_contents(resource_path('views/livewire/project/shared/dns-provider-management.blade.php'));
+
+ expect($dnsMenu)
+ ->toContain('$dnsAuthResource = property_exists($this, \'application\') ? $this->application : $this->service')
+ ->toMatch('/
]*wire:click="createManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/');
+
+ expect($providerModal)
+ ->toContain('$dnsAuthResource = property_exists($this, \'application\') ? $this->application : $this->service')
+ ->toMatch('/]*submitAction="replaceManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/')
+ ->toMatch('/]*wire:click="createManagedDnsRecord)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$dnsAuthResource")[^>]*>/');
+});
+
+test('removing a domain deletes only the managed dns record for that resource', function () {
+ $this->withoutVite();
+ config(['app.maintenance.driver' => 'file']);
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['id' => 0, 'is_dns_validation_enabled' => false],
+ ));
+
+ $team = Team::factory()->create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+ $this->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ $server = Server::factory()->create(['team_id' => $team->id, 'ip' => '203.0.113.10']);
+ $server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
+ $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
+ ['server_id' => $server->id, 'network' => 'coolify'],
+ ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'],
+ ));
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ $application = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ 'fqdn' => 'https://app.example.com',
+ 'build_pack' => 'nixpacks',
+ ]);
+ $application->settings()->update(['is_container_label_readonly_enabled' => true]);
+
+ $otherApplication = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ 'fqdn' => 'https://other.example.com',
+ 'build_pack' => 'nixpacks',
+ ]);
+
+ $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare', 'token' => 'secret']);
+ $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
+
+ $otherRecord = ManagedDnsRecord::factory()->create([
+ 'team_id' => $team->id,
+ 'integration_token_id' => $token->id,
+ 'dns_provider_zone_id' => $zone->id,
+ 'resource_type' => $otherApplication->getMorphClass(),
+ 'resource_id' => $otherApplication->getKey(),
+ 'provider_record_id' => 'record-other',
+ 'type' => 'A',
+ 'name' => 'app.example.com',
+ 'content' => '203.0.113.10',
+ ]);
+ $ownRecord = ManagedDnsRecord::factory()->create([
+ 'team_id' => $team->id,
+ 'integration_token_id' => $token->id,
+ 'dns_provider_zone_id' => $zone->id,
+ 'resource_type' => $application->getMorphClass(),
+ 'resource_id' => $application->getKey(),
+ 'provider_record_id' => 'record-own',
+ 'type' => 'A',
+ 'name' => 'app.example.com',
+ 'content' => '203.0.113.10',
+ ]);
+
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/*/dns_records/record-own' => Http::sequence()
+ ->push(['success' => true, 'result' => ['id' => 'record-own', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10']])
+ ->push(['success' => true, 'result' => ['id' => 'record-own']]),
+ 'https://api.cloudflare.com/client/v4/zones/*/dns_records/record-other' => Http::response([
+ 'success' => true,
+ 'result' => ['id' => 'record-other', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10'],
+ ]),
+ ]);
+
+ Livewire::test(Domains::class, ['application' => $application->fresh()])
+ ->call('removeDomain', 0, '', ['deleteManagedDns'])
+ ->assertDispatched('success');
+
+ expect(ManagedDnsRecord::query()->find($ownRecord->id))->toBeNull()
+ ->and(ManagedDnsRecord::query()->find($otherRecord->id))->not->toBeNull();
+
+ Http::assertSent(fn ($request) => str_contains($request->url(), 'record-own') && $request->method() === 'DELETE');
+ Http::assertNotSent(fn ($request) => str_contains($request->url(), 'record-other'));
+});
+
+test('replaceRecord updates the cloudflare record when the conflict still matches', function () {
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+ $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
+
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::response([
+ 'success' => true, 'result' => ['id' => 'record-1'],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']],
+ ]),
+ ]);
+
+ $record = app(CloudflareDnsProvider::class)->replaceRecord(
+ $zone, 'record-1', 'app.example.com', '203.0.113.10', expectedCurrent: '198.51.100.50',
+ );
+
+ expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10');
+ Http::assertSent(fn ($request) => $request->method() === 'PUT'
+ && str_ends_with($request->url(), '/dns_records/record-1')
+ && $request->data()['name'] === 'app.example.com'
+ && $request->data()['content'] === '203.0.113.10');
+});
+
+test('replaceRecord rejects a stale or tampered conflict without updating dns', function (string $recordId, string $current) {
+ $token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+ $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
+
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/*' => Http::response(['success' => true, 'result' => ['id' => 'record-other']]),
+ ]);
+
+ expect(fn () => app(CloudflareDnsProvider::class)->replaceRecord(
+ $zone, $recordId, 'app.example.com', '203.0.113.10', expectedCurrent: $current,
+ ))->toThrow(RuntimeException::class, 'The DNS conflict is no longer available. Check the record again.');
+
+ Http::assertNotSent(fn ($request) => $request->method() === 'PUT');
+})->with([
+ 'wrong record id' => ['record-other', '198.51.100.50'],
+ 'wrong current value' => ['record-1', '203.0.113.99'],
+]);
+
+test('replacing a managed dns record uses the server ip and live cloudflare record id', function () {
+ ['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication();
+
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::response([
+ 'success' => true, 'result' => ['id' => 'record-1'],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']],
+ ]),
+ ]);
+
+ Livewire::test(Domains::class, ['application' => $application->fresh()])
+ ->set('dnsProviderConflicts', [
+ 'app.example.com|'.$zone->id => [
+ 'record_id' => 'record-1',
+ 'current' => '198.51.100.50',
+ 'proposed' => '198.51.100.1',
+ ],
+ ])
+ ->call('replaceManagedDnsRecord', 'app.example.com', $zone->id)
+ ->assertDispatched('success', 'DNS record replaced for app.example.com.');
+
+ Http::assertSent(fn ($request) => $request->method() === 'PUT'
+ && str_ends_with($request->url(), '/dns_records/record-1')
+ && $request->data()['content'] === '203.0.113.10');
+ expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->where('content', '203.0.113.10')->exists())->toBeTrue();
+});
+
+test('replacing a managed dns record ignores a tampered conflict record id', function () {
+ ['application' => $application, 'zone' => $zone] = prepareManagedDnsApplication();
+
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50']],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/*' => Http::response([
+ 'success' => true, 'result' => ['id' => 'record-other'],
+ ]),
+ ]);
+
+ Livewire::test(Domains::class, ['application' => $application->fresh()])
+ ->set('dnsProviderConflicts', [
+ 'app.example.com|'.$zone->id => [
+ 'record_id' => 'record-other',
+ 'current' => '198.51.100.50',
+ 'proposed' => '198.51.100.1',
+ ],
+ ])
+ ->call('replaceManagedDnsRecord', 'app.example.com', $zone->id)
+ ->assertDispatched('error', 'The DNS conflict is no longer available. Check the record again.')
+ ->assertSet('dnsProviderConflicts', []);
+
+ Http::assertNotSent(fn ($request) => $request->method() === 'PUT');
+ expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeFalse();
+});
+
+/**
+ * @return array{application: Application, zone: DnsProviderZone}
+ */
+function prepareManagedDnsApplication(): array
+{
+ test()->withoutVite();
+ config(['app.maintenance.driver' => 'file']);
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['id' => 0, 'is_dns_validation_enabled' => false],
+ ));
+
+ $team = Team::factory()->create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+ test()->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ $server = Server::factory()->create(['team_id' => $team->id, 'ip' => '203.0.113.10']);
+ $server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
+ $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
+ ['server_id' => $server->id, 'network' => 'coolify'],
+ ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'],
+ ));
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ $application = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ 'fqdn' => 'https://app.example.com',
+ 'build_pack' => 'nixpacks',
+ ]);
+ $application->settings()->update(['is_container_label_readonly_enabled' => true]);
+
+ $token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare', 'token' => 'secret']);
+ $zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
+
+ return ['application' => $application, 'zone' => $zone];
+}
diff --git a/tests/Feature/EnableActionButtonsTest.php b/tests/Feature/EnableActionButtonsTest.php
new file mode 100644
index 0000000000..01639d4979
--- /dev/null
+++ b/tests/Feature/EnableActionButtonsTest.php
@@ -0,0 +1,179 @@
+create();
+ $user = User::factory()->create(['email' => 'owner@example.com']);
+ $user->teams()->attach($team, ['role' => 'owner']);
+
+ session(['currentTeam' => $team]);
+ test()->actingAs($user);
+
+ return [$user, $team];
+}
+
+function actingAsEnableActionInstanceAdmin(): User
+{
+ $team = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
+ $user = User::factory()->create(['id' => 0, 'email' => 'root-enable-actions@example.com']);
+ if (! $user->teams()->whereKey($team->id)->exists()) {
+ $user->teams()->attach($team, ['role' => 'owner']);
+ }
+
+ session(['currentTeam' => $team]);
+ test()->actingAs($user);
+
+ return $user;
+}
+
+beforeEach(function () {
+ InstanceSettings::forceCreate(['id' => 0]);
+ Once::flush();
+});
+
+it('renders settings email enable actions instead of enabled checkboxes', function () {
+ $view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
+
+ expect($view)->toContain('Enable SMTP Server')
+ ->and($view)->toContain('Disable SMTP Server')
+ ->and($view)->toContain('Enable Resend')
+ ->and($view)->toContain('Disable Resend')
+ ->and($view)->not->toContain('id="smtpEnabled" label="Enabled"')
+ ->and($view)->not->toContain('id="resendEnabled" label="Enabled"');
+});
+
+it('keeps transactional smtp disabled when enable validation fails', function () {
+ actingAsEnableActionInstanceAdmin();
+
+ Livewire::test(SettingsEmail::class)
+ ->call('toggleSmtp')
+ ->assertDispatched('error')
+ ->assertSet('smtpEnabled', false);
+
+ expect(instanceSettings()->fresh()->smtp_enabled)->toBeFalse();
+});
+
+it('enables transactional smtp only after required fields validate', function () {
+ actingAsEnableActionInstanceAdmin();
+
+ Livewire::test(SettingsEmail::class)
+ ->set('smtpFromAddress', 'mail@example.com')
+ ->set('smtpFromName', 'Coolify')
+ ->set('smtpHost', 'smtp.example.com')
+ ->set('smtpPort', '587')
+ ->set('smtpEncryption', 'starttls')
+ ->call('toggleSmtp')
+ ->assertHasNoErrors()
+ ->assertSet('smtpEnabled', true)
+ ->assertSet('resendEnabled', false);
+
+ expect(instanceSettings()->fresh()->smtp_enabled)->toBeTrue()
+ ->and(instanceSettings()->fresh()->resend_enabled)->toBeFalse();
+});
+
+it('renders notification provider enable actions instead of enabled checkboxes', function (string $view, string $enableLabel, string $checkboxSnippet) {
+ $contents = file_get_contents(resource_path("views/livewire/notifications/{$view}.blade.php"));
+
+ expect($contents)->toContain($enableLabel)
+ ->and($contents)->not->toContain($checkboxSnippet);
+})->with([
+ 'discord' => ['discord', 'Enable Discord', 'id="discordEnabled" label="Enabled"'],
+ 'slack' => ['slack', 'Enable Slack', 'id="slackEnabled" label="Enabled"'],
+ 'telegram' => ['telegram', 'Enable Telegram', 'id="telegramEnabled" label="Enabled"'],
+ 'pushover' => ['pushover', 'Enable Pushover', 'id="pushoverEnabled" label="Enabled"'],
+ 'webhook' => ['webhook', 'Enable Webhook', 'id="webhookEnabled" label="Enabled"'],
+]);
+
+it('shows notification provider save buttons while disabled', function (string $component) {
+ actingAsEnableActionOwner();
+
+ Livewire::test($component)
+ ->assertSet(str(class_basename($component))->camel()->append('Enabled')->toString(), false)
+ ->assertSee('Save');
+})->with([
+ 'discord' => [Discord::class],
+ 'slack' => [Slack::class],
+ 'telegram' => [Telegram::class],
+ 'pushover' => [Pushover::class],
+ 'webhook' => [Webhook::class],
+]);
+
+it('hides notification provider test buttons while disabled and shows them when enabled', function (string $component, string $enabledProperty) {
+ actingAsEnableActionOwner();
+
+ Livewire::test($component)
+ ->assertDontSee('Send Test Notification');
+
+ Livewire::test($component)
+ ->set($enabledProperty, true)
+ ->assertSee('Send Test Notification');
+})->with([
+ 'discord' => [Discord::class, 'discordEnabled'],
+ 'slack' => [Slack::class, 'slackEnabled'],
+ 'telegram' => [Telegram::class, 'telegramEnabled'],
+ 'pushover' => [Pushover::class, 'pushoverEnabled'],
+ 'webhook' => [Webhook::class, 'webhookEnabled'],
+]);
+
+it('hides the email test button while email notifications are disabled', function () {
+ actingAsEnableActionOwner();
+
+ Livewire::test(Email::class)
+ ->assertDontSee('Send Test Email');
+});
+
+it('keeps notification providers disabled when enable validation fails', function (string $component, string $method, string $enabledProperty, string $requiredField, string $settingsRelation, string $settingsColumn) {
+ [, $team] = actingAsEnableActionOwner();
+
+ Livewire::test($component)
+ ->call($method)
+ ->assertDispatched('error')
+ ->assertSet($enabledProperty, false);
+
+ expect($team->{$settingsRelation}->fresh()->{$settingsColumn})->toBeFalse();
+})->with([
+ 'discord' => [Discord::class, 'toggleDiscordEnabled', 'discordEnabled', 'discordWebhookUrl', 'discordNotificationSettings', 'discord_enabled'],
+ 'slack' => [Slack::class, 'toggleSlackEnabled', 'slackEnabled', 'slackWebhookUrl', 'slackNotificationSettings', 'slack_enabled'],
+ 'telegram' => [Telegram::class, 'toggleTelegramEnabled', 'telegramEnabled', 'telegramToken', 'telegramNotificationSettings', 'telegram_enabled'],
+ 'pushover' => [Pushover::class, 'togglePushoverEnabled', 'pushoverEnabled', 'pushoverUserKey', 'pushoverNotificationSettings', 'pushover_enabled'],
+ 'webhook' => [Webhook::class, 'toggleWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', 'webhook_enabled'],
+]);
+
+it('renders notification email and log drain enable actions instead of enabled checkboxes', function () {
+ $notificationEmail = file_get_contents(resource_path('views/livewire/notifications/email.blade.php'));
+ $logDrains = file_get_contents(resource_path('views/livewire/server/log-drains.blade.php'));
+
+ expect($notificationEmail)->toContain('Enable SMTP Server')
+ ->and($notificationEmail)->toContain('Enable Resend')
+ ->and($notificationEmail)->not->toContain('id="smtpEnabled"')
+ ->and($notificationEmail)->not->toContain('id="resendEnabled"')
+ ->and($logDrains)->toContain('Enable New Relic')
+ ->and($logDrains)->toContain('Enable Axiom')
+ ->and($logDrains)->toContain('Enable Custom FluentBit')
+ ->and($logDrains)->not->toContain('label="Enabled"');
+});
+
+it('keeps notification email smtp disabled when enable validation fails', function () {
+ actingAsEnableActionOwner();
+
+ Livewire::test(Email::class)
+ ->call('toggleSmtp')
+ ->assertDispatched('error')
+ ->assertSet('smtpEnabled', false);
+});
diff --git a/tests/Feature/EnvVarInputDesignTest.php b/tests/Feature/EnvVarInputDesignTest.php
index 992c4d8b09..9e1403f282 100644
--- a/tests/Feature/EnvVarInputDesignTest.php
+++ b/tests/Feature/EnvVarInputDesignTest.php
@@ -11,3 +11,54 @@ it('uses the current listbox design for environment variable suggestions', funct
->toContain('border-emerald-500/25 bg-emerald-500/10')
->not->toContain('dark:bg-coolgray-100');
});
+
+it('keeps the environment variable input enabled while secret manager keys load', function () {
+ $view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
+
+ expect($view)->toContain('wire:target.except="fetchSecretManagerKeys"');
+});
+
+it('allows secret manager key loading to retry after a failed request', function () {
+ $view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
+ $failureHandler = explode('});', explode('.catch(() => {', $view, 2)[1], 2)[0];
+
+ expect($failureHandler)
+ ->toContain('this.vaultKeysLoading = false;')
+ ->not->toContain("this.availableVars['vault'] = [];");
+});
+
+it('authorizes secret-enabled environment variable inputs at the component boundary', function () {
+ $addView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
+ $showView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
+
+ foreach ([$addView, $showView] as $view) {
+ preg_match('/ /', $view, $matches);
+
+ expect($matches[0] ?? '')
+ ->toContain('canGate="manageEnvironment"')
+ ->toContain(':canResource="$resource"');
+ }
+});
+
+it('passes the remove source warning without compiling remote secret syntax as blade', function () {
+ $view = file_get_contents(resource_path('views/livewire/project/shared/secret-manager-links.blade.php'));
+
+ expect($view)
+ ->toContain('with {{vault.KEY}}. Values are fetched')
+ ->not->toContain('{{doppler.KEY}}')
+ ->not->toContain('{{infisical.KEY}}')
+ ->toContain(':actions="[$removeSourceWarning]"')
+ ->not->toContain(':actions="[\'Existing {{vault.*}}');
+});
+
+it('shows secret manager configuration for applications services and databases', function () {
+ $views = [
+ resource_path('views/livewire/project/application/configuration.blade.php'),
+ resource_path('views/livewire/project/service/configuration.blade.php'),
+ resource_path('views/livewire/project/database/configuration.blade.php'),
+ ];
+
+ foreach ($views as $view) {
+ expect(file_get_contents($view))->toContain('livewire:project.shared.secret-manager-links');
+ }
+});
diff --git a/tests/Feature/EnvironmentVariable/EnvironmentVariableSharedSpacingTest.php b/tests/Feature/EnvironmentVariable/EnvironmentVariableSharedSpacingTest.php
index 2514ae94a6..c299f65350 100644
--- a/tests/Feature/EnvironmentVariable/EnvironmentVariableSharedSpacingTest.php
+++ b/tests/Feature/EnvironmentVariable/EnvironmentVariableSharedSpacingTest.php
@@ -144,6 +144,23 @@ test('is_shared attribute detects variable without spaces', function () {
expect($env->is_shared)->toBeTrue();
});
+test('is_shared persisted value rejects unsupported reference types', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST',
+ 'value' => '{{vault.KEY}}',
+ 'resource_id' => $this->application->id,
+ 'resource_type' => $this->application->getMorphClass(),
+ ]);
+
+ $env->refresh();
+
+ expect($env->is_shared)->toBeFalse()
+ ->and(EnvironmentVariable::query()
+ ->whereKey($env->id)
+ ->where('is_shared', false)
+ ->exists())->toBeTrue();
+});
+
test('non-shared variable preserves spaces', function () {
$env = EnvironmentVariable::create([
'key' => 'REGULAR',
diff --git a/tests/Feature/EnvironmentVariableAsyncLoadTest.php b/tests/Feature/EnvironmentVariableAsyncLoadTest.php
index 4d83862b6e..d98313a9f8 100644
--- a/tests/Feature/EnvironmentVariableAsyncLoadTest.php
+++ b/tests/Feature/EnvironmentVariableAsyncLoadTest.php
@@ -71,7 +71,7 @@ it('loads environment variables when loadEnvironmentVariables is called', functi
->assertSee('Loading environment variables...')
->call('loadEnvironmentVariables')
->assertSet('readyToLoad', true)
- ->assertDontSee('Loading environment variables...')
+ ->assertDontSeeText('Loading environment variables...')
->assertSee('API_KEY');
expect($component->instance()->environmentVariables->pluck('key')->all())
diff --git a/tests/Feature/EnvironmentVariableCopyValueTest.php b/tests/Feature/EnvironmentVariableCopyValueTest.php
new file mode 100644
index 0000000000..b12105ea84
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableCopyValueTest.php
@@ -0,0 +1,151 @@
+ 0]);
+
+ $this->user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+ $this->application = Application::factory()->create(['environment_id' => $this->environment->id]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+function createEnvironmentVariable(array $attributes = []): EnvironmentVariable
+{
+ return EnvironmentVariable::create(array_merge([
+ 'key' => 'API_KEY',
+ 'value' => 'secret-value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => test()->application->id,
+ ], $attributes));
+}
+
+function assertCopiedValue(EnvironmentVariable|SharedEnvironmentVariable $env, ?string $expected): void
+{
+ Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
+ ->call('copyValue')
+ ->assertReturned($expected);
+}
+
+function assertCopiedComposeValue(string $value, ?string $expected): void
+{
+ Livewire::test(ShowHardcoded::class, [
+ 'env' => ['key' => 'MYSQL_USER', 'value' => $value],
+ 'resourceableType' => Application::class,
+ 'resourceableId' => test()->application->id,
+ ])
+ ->call('copyValue')
+ ->assertReturned($expected);
+}
+
+test('copies the plain value', function () {
+ assertCopiedValue(createEnvironmentVariable(), 'secret-value');
+});
+
+test('copies the referenced variable value instead of the reference', function (string $reference) {
+ createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']);
+
+ assertCopiedValue(createEnvironmentVariable(['key' => 'MYSQL_USER', 'value' => $reference]), 'classicpress-user');
+})->with(['bare' => '$SERVICE_USER_CLASSICPRESS', 'braced' => '${SERVICE_USER_CLASSICPRESS}']);
+
+test('copies the resolved shared variable value', function () {
+ SharedEnvironmentVariable::create([
+ 'key' => 'MY_SECRET',
+ 'value' => 'resolved-secret',
+ 'type' => 'team',
+ 'team_id' => $this->team->id,
+ ]);
+
+ assertCopiedValue(createEnvironmentVariable(['value' => '{{team.MY_SECRET}}']), 'resolved-secret');
+});
+
+test('copies embedded, literal and unknown references as stored', function () {
+ createEnvironmentVariable(['key' => 'SERVICE_PASSWORD_MYSQL', 'value' => 'generated-password']);
+
+ assertCopiedValue(
+ createEnvironmentVariable(['key' => 'DATABASE_URL', 'value' => 'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306']),
+ 'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306',
+ );
+ assertCopiedValue(
+ createEnvironmentVariable(['key' => 'LITERAL', 'value' => '$SERVICE_PASSWORD_MYSQL', 'is_literal' => true]),
+ '$SERVICE_PASSWORD_MYSQL',
+ );
+ assertCopiedValue(createEnvironmentVariable(['key' => 'UNKNOWN', 'value' => '$DOES_NOT_EXIST']), '$DOES_NOT_EXIST');
+});
+
+test('copies literal values without .env-style quoting', function () {
+ $env = createEnvironmentVariable(['value' => 'pa$$word', 'is_literal' => true]);
+
+ expect($env->real_value)->toBe("'pa\$\$word'");
+ assertCopiedValue($env, 'pa$$word');
+});
+
+test('copies the value of a shared environment variable row', function () {
+ $shared = SharedEnvironmentVariable::create([
+ 'key' => 'TEAM_WIDE',
+ 'value' => 'team-wide-value',
+ 'type' => 'team',
+ 'team_id' => $this->team->id,
+ ]);
+
+ assertCopiedValue($shared, 'team-wide-value');
+});
+
+test('members get no copy button and no value', function () {
+ $member = User::factory()->create();
+ $this->team->members()->attach($member, ['role' => 'member']);
+ $this->actingAs($member);
+
+ Livewire::test(Show::class, ['env' => createEnvironmentVariable(), 'type' => 'application'])
+ ->assertDontSeeHtml('Copy value')
+ ->call('copyValue')
+ ->assertReturned(null);
+});
+
+test('locked variables get no copy button and no value', function () {
+ Livewire::test(Show::class, ['env' => createEnvironmentVariable(['is_shown_once' => true]), 'type' => 'application'])
+ ->assertDontSeeHtml('Copy value')
+ ->call('copyValue')
+ ->assertReturned(null);
+});
+
+test('compose-managed rows copy the referenced variable value', function () {
+ createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']);
+
+ assertCopiedComposeValue('$SERVICE_USER_CLASSICPRESS', 'classicpress-user');
+ assertCopiedComposeValue('production', 'production');
+});
+
+test('compose-managed rows hide copying from members', function () {
+ $member = User::factory()->create();
+ $this->team->members()->attach($member, ['role' => 'member']);
+ $this->actingAs($member);
+
+ Livewire::test(ShowHardcoded::class, [
+ 'env' => ['key' => 'MYSQL_USER', 'value' => '$SERVICE_USER_CLASSICPRESS'],
+ 'resourceableType' => Application::class,
+ 'resourceableId' => $this->application->id,
+ ])
+ ->assertDontSeeHtml('Copy value')
+ ->call('copyValue')
+ ->assertReturned(null);
+});
diff --git a/tests/Feature/FileStorageMountPathTest.php b/tests/Feature/FileStorageMountPathTest.php
index 0b9e450a29..cd301c2557 100644
--- a/tests/Feature/FileStorageMountPathTest.php
+++ b/tests/Feature/FileStorageMountPathTest.php
@@ -4,7 +4,6 @@ use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Service\FileStorage;
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Shared\Storages\All;
-use App\Livewire\Project\Shared\Storages\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
@@ -247,8 +246,8 @@ test('deleting a volume mount refreshes the configuration warning', function ()
'resource_type' => $database->getMorphClass(),
]);
- Livewire::test(Show::class, ['storage' => $volume, 'resource' => $database])
- ->call('delete', 'password')
+ Livewire::test(All::class, ['resource' => $database])
+ ->call('delete', $volume->id, 'password')
->assertDispatched('configurationChanged');
expect($volume->fresh())->toBeNull();
diff --git a/tests/Feature/ListboxTriggerTruncationTest.php b/tests/Feature/ListboxTriggerTruncationTest.php
index a7cd462954..66fccf87a0 100644
--- a/tests/Feature/ListboxTriggerTruncationTest.php
+++ b/tests/Feature/ListboxTriggerTruncationTest.php
@@ -142,7 +142,7 @@ test('listbox waits for change handlers and prevents overlapping selections', fu
->toContain('saving: false')
->toContain('async choose(option)')
->toContain('await this.$wire.')
- ->toContain('if (this.saving || option.disabled) return;')
+ ->toContain('if (option.header || option.disabled || this.saving) return;')
->toContain("'pointer-events-none opacity-70': saving");
});
diff --git a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php
index 079bd6daa4..ac381762cb 100644
--- a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php
+++ b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php
@@ -104,3 +104,47 @@ it('only shows the custom container name for consistent naming', function () {
->set('isConsistentContainerNameEnabled', true)
->assertSee('Custom container name');
});
+
+it('saves a slugged container name prefix in generated naming mode', function () {
+ $otherTeamApplication = createApplicationForContainerNamingTest();
+ $otherTeamApplication->settings->update(['custom_container_name_prefix' => 'my-api']);
+
+ $application = createApplicationForContainerNamingTest();
+ $application->settings->update(['custom_internal_name' => 'legacy-name']);
+ $application = $application->fresh(['environment.project', 'settings', 'destination']);
+
+ Livewire::test(Advanced::class, ['application' => $application])
+ ->assertSee('Container name prefix')
+ ->set('customContainerNamePrefix', 'My API')
+ ->call('saveCustomNamePrefix')
+ ->assertDispatched('success')
+ ->assertSet('customContainerNamePrefix', 'my-api');
+
+ $settings = $application->settings()->first();
+ expect($settings->custom_container_name_prefix)->toBe('my-api')
+ ->and($settings->custom_internal_name)->toBe('legacy-name');
+});
+
+it('rejects a container name prefix already used on the server', function () {
+ $application = createApplicationForContainerNamingTest();
+ $sibling = fn () => Application::factory()->create([
+ 'environment_id' => $application->environment_id,
+ 'destination_id' => $application->destination_id,
+ 'destination_type' => $application->destination_type,
+ ]);
+ $prefixedApplication = $sibling();
+ $prefixedApplication->settings->update(['custom_container_name_prefix' => 'shared-prefix']);
+ $sibling()->settings->update(['custom_internal_name' => 'api']);
+
+ $application = $application->fresh(['environment.project', 'settings', 'destination']);
+ $component = Livewire::test(Advanced::class, ['application' => $application]);
+
+ foreach (['shared-prefix', 'api', $prefixedApplication->uuid] as $takenPrefix) {
+ $component->set('customContainerNamePrefix', $takenPrefix)
+ ->call('saveCustomNamePrefix')
+ ->assertDispatched('error')
+ ->assertSet('customContainerNamePrefix', null);
+ }
+
+ expect($application->settings()->first()->custom_container_name_prefix)->toBeNull();
+});
diff --git a/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php
new file mode 100644
index 0000000000..994c96398b
--- /dev/null
+++ b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php
@@ -0,0 +1,45 @@
+user = User::factory()->create();
+ $this->team = $this->user->teams()->first();
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+it('reverts the persisted enabled flag when starting the log drain fails', function () {
+ StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom'));
+
+ expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy();
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainNewRelicLicenseKey', 'abc123')
+ ->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com')
+ ->call('toggleLogDrain', 'newrelic')
+ ->assertSet('isLogDrainNewRelicEnabled', false);
+
+ expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy();
+});
+
+it('keeps the enabled flag persisted when starting the log drain succeeds', function () {
+ StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok');
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainNewRelicLicenseKey', 'abc123')
+ ->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com')
+ ->call('toggleLogDrain', 'newrelic')
+ ->assertSet('isLogDrainNewRelicEnabled', true);
+
+ expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeTruthy();
+});
diff --git a/tests/Feature/LoginPageBrandingTest.php b/tests/Feature/LoginPageBrandingTest.php
index 5d60290abd..ffe7da67ce 100644
--- a/tests/Feature/LoginPageBrandingTest.php
+++ b/tests/Feature/LoginPageBrandingTest.php
@@ -37,6 +37,28 @@ test('auth pages use the Coollabs purple background glow', function () {
->not->toMatch('/\.auth-shell\s*\{[^}]*color-mix\(in oklab, var\(--color-accent\) 9%, transparent\)/s');
});
+test('external login providers are centered and full width', function () {
+ $login = file_get_contents(resource_path('views/auth/login.blade.php'));
+
+ expect($login)
+ ->toContain('class="flex flex-col gap-2"')
+ ->toContain('class="w-full justify-center"')
+ ->not->toContain('sm:w-[calc(50%-0.25rem)]');
+});
+
+test('external login providers display their icons except oidc', function () {
+ $login = file_get_contents(resource_path('views/auth/login.blade.php'));
+
+ expect($login)
+ ->toContain("@if (\$provider_setting->provider !== 'oidc')")
+ ->toContain("asset('svgs/'.\$provider_setting->provider.'.svg')")
+ ->toContain('class="size-5 shrink-0 dark:invert"');
+
+ foreach (['authentik', 'azure', 'bitbucket', 'clerk', 'discord', 'github', 'gitlab', 'google', 'infomaniak', 'zitadel'] as $provider) {
+ expect(public_path("svgs/{$provider}.svg"))->toBeFile();
+ }
+});
+
test('error pages use the Coollabs purple background glow', function () {
$styles = file_get_contents(resource_path('css/app.css'));
diff --git a/tests/Feature/MetricsDisabledEmptyStateTest.php b/tests/Feature/MetricsDisabledEmptyStateTest.php
new file mode 100644
index 0000000000..1765345b60
--- /dev/null
+++ b/tests/Feature/MetricsDisabledEmptyStateTest.php
@@ -0,0 +1,11 @@
+toContain('toContain('description="Enable Sentinel and metrics for this server before collecting application usage data."')
+ ->toContain('icon-name="dashboard"')
+ ->not->toContain('');
+});
diff --git a/tests/Feature/MetricsTooltipStylingTest.php b/tests/Feature/MetricsTooltipStylingTest.php
index f748487e59..37d3764669 100644
--- a/tests/Feature/MetricsTooltipStylingTest.php
+++ b/tests/Feature/MetricsTooltipStylingTest.php
@@ -29,3 +29,14 @@ test('metrics charts render custom tooltip content', function (string $view) {
'server metrics' => 'views/livewire/server/charts.blade.php',
'resource metrics' => 'views/livewire/project/shared/metrics.blade.php',
]);
+
+test('server metric tooltips show local and UTC timestamps', function (string $view) {
+ expect(file_get_contents(resource_path($view)))
+ ->toContain('formatLocalTimestamp(timestamp)')
+ ->toContain('formatUtcTimestamp(timestamp)')
+ ->toContain('Your time:')
+ ->toContain('UTC:');
+})->with([
+ 'dashboard server metrics' => 'views/livewire/dashboard/server-metrics-chart.blade.php',
+ 'server metrics' => 'views/livewire/server/charts.blade.php',
+]);
diff --git a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php
index cdc96c2bb5..510e8462aa 100644
--- a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php
+++ b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php
@@ -19,6 +19,19 @@ it('auto-disables listboxes when the gate denies access', function () {
expect($html)->toMatch('/]*id="status-trigger"[^>]*\sdisabled(?:[=\s>])/');
});
+it('auto-disables checkboxes when the gate denies access', function () {
+ Gate::define('update-checkbox-test', fn (): bool => false);
+
+ $html = Blade::render(<<<'BLADE'
+
+ BLADE);
+
+ expect($html)
+ ->toMatch('/ ]*type="checkbox"[^>]*\sdisabled(?:[=\s>])/')
+ ->not->toContain('canGate');
+});
+
it('declares gate attributes on form controls with update permission checks', function () {
$controlPattern = '/ |<\/x-forms\.[^>]+>)/s';
@@ -43,7 +56,7 @@ it('hides resource action menus when the user cannot manage the resource', funct
foreach (['mobile', 'desktop'] as $viewport) {
expect($source)->toMatch(
- "/@can\\('{$ability}', \\$".$resource."\\)[\\s\\S]*?with([
@@ -69,6 +82,19 @@ it('declares deploy authorization on the service container removal confirmation'
);
});
+it('declares update authorization on application and service domain removal confirmations', function () {
+ $applicationRow = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
+ $serviceTable = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
+
+ expect($applicationRow)->toMatch(
+ '/
]*title="Remove domain\?")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$application")[^>]*>/'
+ );
+
+ expect($serviceTable)->toMatch(
+ '/]*title="Remove domain\?")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/'
+ );
+});
+
it('declares update authorization on service backup mutation controls', function () {
$importBackupView = file_get_contents(resource_path('views/livewire/project/service/import-backup.blade.php'));
$volumeBackupView = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php
index 1388e29808..4671183ae7 100644
--- a/tests/Feature/OauthControllerTest.php
+++ b/tests/Feature/OauthControllerTest.php
@@ -1,25 +1,35 @@
0,
'is_registration_enabled' => false,
]);
+ Once::flush();
+
OauthSetting::create([
'provider' => 'google',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
'tenant' => 'example.com',
+ 'enabled' => true,
]);
});
@@ -46,6 +56,75 @@ it('logs in an existing user when the oauth provider returns a mixed-case email'
$response->assertRedirect('/');
$this->assertAuthenticatedAs($user);
expect(User::count())->toBe(1);
+ expect(OauthIdentity::where([
+ 'user_id' => $user->id,
+ 'provider' => 'google',
+ 'provider_user_id' => 'google-user-id',
+ ])->exists())->toBeTrue();
+});
+
+it('never moves an existing oauth identity when the provider email changes', function () {
+ config()->set('app.maintenance.driver', 'file');
+
+ $identityOwner = User::factory()->create(['email' => 'old@example.com']);
+ $otherUser = User::factory()->create(['email' => 'new@example.com']);
+ $identity = OauthIdentity::create([
+ 'user_id' => $identityOwner->id,
+ 'provider' => 'google',
+ 'issuer' => 'google',
+ 'provider_user_id' => 'google-user-id',
+ 'email' => 'old@example.com',
+ ]);
+
+ $provider = Mockery::mock();
+ $provider->shouldReceive('setConfig')->once()->andReturnSelf();
+ $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
+ $provider->shouldReceive('user')->once()->andReturn((object) [
+ 'email' => 'new@example.com',
+ 'name' => 'Example User',
+ 'id' => 'google-user-id',
+ ]);
+
+ Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
+
+ $this->get(route('auth.callback', 'google'))->assertRedirect('/');
+
+ $this->assertAuthenticatedAs($identityOwner);
+ expect($identity->refresh()->user_id)->toBe($identityOwner->id)
+ ->and($identity->email)->toBe('new@example.com')
+ ->and($identity->user_id)->not->toBe($otherUser->id);
+});
+
+it('continues oauth login when another request creates the identity first', function () {
+ $user = User::factory()->create(['email' => 'race@example.com']);
+ $eventName = 'eloquent.creating: '.OauthIdentity::class;
+
+ Event::listen($eventName, function (OauthIdentity $identity): void {
+ $attributes = $identity->getAttributes();
+
+ DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes));
+
+ throw new UniqueConstraintViolationException(
+ DB::getDefaultConnection(),
+ 'insert into oauth_identities',
+ [],
+ new PDOException('duplicate identity'),
+ );
+ });
+
+ try {
+ $resolvedUser = app(OauthLoginService::class)->login('google', (object) [
+ 'email' => 'race@example.com',
+ 'name' => 'Race User',
+ 'id' => 'google-race-id',
+ ], OauthSetting::where('provider', 'google')->firstOrFail());
+ } finally {
+ Event::forget($eventName);
+ }
+
+ expect($resolvedUser->is($user))->toBeTrue()
+ ->and(OauthIdentity::where('provider_user_id', 'google-race-id')->count())->toBe(1);
+ $this->assertAuthenticatedAs($user);
});
it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) {
@@ -76,4 +155,37 @@ it('rejects oauth logins when the provider does not return an email address', fu
})->with([
'null email' => [null],
'blank email' => [' '],
+ 'malformed email' => ['not-an-email'],
+ 'missing domain' => ['user@'],
+]);
+
+it('rejects oauth logins when the provider does not return a valid user id', function (mixed $invalidId) {
+ $oauthUser = (object) [
+ 'email' => 'user@example.edu',
+ 'name' => 'Example User',
+ ];
+
+ if ($invalidId !== 'missing') {
+ $oauthUser->id = $invalidId;
+ }
+
+ try {
+ app(OauthLoginService::class)->login('google', $oauthUser, OauthSetting::where('provider', 'google')->firstOrFail());
+ } catch (HttpException $exception) {
+ expect($exception->getStatusCode())->toBe(403)
+ ->and(OauthIdentity::count())->toBe(0)
+ ->and(User::count())->toBe(0);
+
+ return;
+ }
+
+ $this->fail('Expected an invalid OAuth provider user ID to be rejected.');
+})->with([
+ 'null id' => [null],
+ 'missing id' => ['missing'],
+ 'blank id' => [' '],
+ 'non-scalar id' => [[]],
+ 'true id' => [true],
+ 'false id' => [false],
+ 'float id' => [1.0],
]);
diff --git a/tests/Feature/OauthRegistrationPolicyTest.php b/tests/Feature/OauthRegistrationPolicyTest.php
new file mode 100644
index 0000000000..86186cca8c
--- /dev/null
+++ b/tests/Feature/OauthRegistrationPolicyTest.php
@@ -0,0 +1,52 @@
+ 0,
+ 'is_registration_enabled' => true,
+ 'disable_registration_when_oauth_enabled' => true,
+ ]);
+ Once::flush();
+});
+
+it('blocks password registration when oauth registration policy disables it', function () {
+ OauthSetting::create([
+ 'provider' => 'oidc',
+ 'enabled' => true,
+ 'client_id' => 'client-id',
+ 'client_secret' => 'secret',
+ 'base_url' => 'https://idp.example.com',
+ ]);
+
+ app(CreateNewUser::class)->create([
+ 'name' => 'Password User',
+ 'email' => 'password@example.com',
+ 'password' => 'password',
+ 'password_confirmation' => 'password',
+ ]);
+})->throws(HttpException::class);
+
+it('allows password registration when no oauth provider is enabled', function () {
+ OauthSetting::create([
+ 'provider' => 'oidc',
+ 'enabled' => false,
+ ]);
+
+ $user = app(CreateNewUser::class)->create([
+ 'name' => 'Password User',
+ 'email' => 'password@example.com',
+ 'password' => 'password',
+ 'password_confirmation' => 'password',
+ ]);
+
+ expect($user->email)->toBe('password@example.com');
+});
diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php
new file mode 100644
index 0000000000..084347f66d
--- /dev/null
+++ b/tests/Feature/OidcOauthControllerTest.php
@@ -0,0 +1,275 @@
+set('app.maintenance.driver', 'file');
+
+ InstanceSettings::forceCreate([
+ 'id' => 0,
+ 'is_registration_enabled' => false,
+ ]);
+
+ Once::flush();
+
+ OauthSetting::create([
+ 'provider' => 'oidc',
+ 'enabled' => true,
+ 'client_id' => 'client-id',
+ 'client_secret' => 'client-secret',
+ 'base_url' => 'https://idp.example.com',
+ 'redirect_uri' => 'https://coolify.example.com/auth/oidc/callback',
+ 'allow_registration' => false,
+ ]);
+});
+
+function fakeOidcProvider(array $claims = []): void
+{
+ $user = (new OidcUser)->setRaw(array_merge([
+ 'iss' => 'https://idp.example.com',
+ 'sub' => 'okta-user-1',
+ 'email' => 'user@example.com',
+ 'email_verified' => true,
+ 'name' => 'Okta User',
+ ], $claims))->map([
+ 'id' => $claims['sub'] ?? 'okta-user-1',
+ 'name' => $claims['name'] ?? 'Okta User',
+ 'email' => $claims['email'] ?? 'user@example.com',
+ ]);
+
+ $provider = Mockery::mock();
+ $provider->shouldReceive('setConfig')->andReturnSelf();
+ $provider->shouldReceive('user')->andReturn($user);
+
+ Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
+}
+
+it('logs in a user through an existing oidc identity', function () {
+ $user = User::factory()->create(['email' => 'existing@example.com']);
+ OauthIdentity::create([
+ 'user_id' => $user->id,
+ 'provider' => 'oidc',
+ 'issuer' => 'https://idp.example.com',
+ 'provider_user_id' => 'okta-user-1',
+ 'email' => 'existing@example.com',
+ ]);
+
+ fakeOidcProvider(['email' => 'existing@example.com']);
+
+ $response = $this->get(route('auth.callback', 'oidc'));
+
+ $response->assertRedirect('/');
+ $this->assertAuthenticatedAs($user);
+});
+
+it('continues oidc login when another request creates the identity first', function () {
+ $user = User::factory()->create(['email' => 'race@example.com']);
+ $eventName = 'eloquent.creating: '.OauthIdentity::class;
+
+ Event::listen($eventName, function (OauthIdentity $identity): void {
+ $attributes = $identity->getAttributes();
+
+ DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes));
+
+ throw new UniqueConstraintViolationException(
+ DB::getDefaultConnection(),
+ 'insert into oauth_identities',
+ [],
+ new PDOException('duplicate identity'),
+ );
+ });
+
+ try {
+ $resolvedUser = app(OauthLoginService::class)->login('oidc', (new OidcUser)->setRaw([
+ 'iss' => 'https://idp.example.com',
+ 'sub' => 'oidc-race-id',
+ 'email' => 'race@example.com',
+ 'email_verified' => true,
+ 'name' => 'Race User',
+ ])->map([
+ 'id' => 'oidc-race-id',
+ 'name' => 'Race User',
+ 'email' => 'race@example.com',
+ ]), OauthSetting::where('provider', 'oidc')->firstOrFail());
+ } finally {
+ Event::forget($eventName);
+ }
+
+ expect($resolvedUser->is($user))->toBeTrue()
+ ->and(OauthIdentity::where('provider_user_id', 'oidc-race-id')->count())->toBe(1);
+ $this->assertAuthenticatedAs($user);
+});
+
+it('creates a new oidc user when provider registration is allowed while normal registration is disabled', function () {
+ OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
+
+ fakeOidcProvider(['email' => 'newuser@example.com']);
+
+ $response = $this->get(route('auth.callback', 'oidc'));
+
+ $response->assertRedirect('/');
+ $user = User::whereEmail('newuser@example.com')->first();
+ expect($user)->not->toBeNull()
+ ->and($user->password)->not->toBeNull();
+ $this->assertAuthenticatedAs($user);
+ $this->assertDatabaseHas('oauth_identities', [
+ 'user_id' => $user->id,
+ 'provider' => 'oidc',
+ 'issuer' => 'https://idp.example.com',
+ 'provider_user_id' => 'okta-user-1',
+ ]);
+});
+
+it('creates a new oidc user in the root team only when provider root auto-join is enabled', function () {
+ Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
+ (new User)->forceFill([
+ 'id' => 0,
+ 'name' => 'Root User',
+ 'email' => 'root@example.com',
+ 'password' => 'password',
+ ])->save();
+
+ OauthSetting::where('provider', 'oidc')->update([
+ 'allow_registration' => true,
+ 'auto_join_root_team' => true,
+ ]);
+
+ fakeOidcProvider(['email' => 'root-member@example.com', 'name' => 'Root Member']);
+
+ $response = $this->get(route('auth.callback', 'oidc'));
+
+ $response->assertRedirect('/');
+ $user = User::whereEmail('root-member@example.com')->first();
+ expect($user)->not->toBeNull()
+ ->and($user->teams()->count())->toBe(1);
+
+ $rootMembership = $user->teams()->where('teams.id', 0)->first();
+ expect($rootMembership)->not->toBeNull()
+ ->and($rootMembership->pivot->role)->toBe('member');
+
+ $this->assertDatabaseMissing('teams', [
+ 'name' => "Root Member's Team",
+ ]);
+ expect(session('currentTeam')->id)->toBe(0);
+ $this->assertAuthenticatedAs($user);
+});
+
+it('rejects linking an unverified oidc email to an existing local account', function () {
+ $user = User::factory()->create(['email' => 'victim@example.com']);
+
+ fakeOidcProvider(['email' => 'victim@example.com', 'email_verified' => false]);
+
+ $response = $this->from('/login')->get(route('auth.callback', 'oidc'));
+
+ $response->assertRedirect('/login');
+ $this->assertGuest();
+ $this->assertDatabaseMissing('oauth_identities', [
+ 'user_id' => $user->id,
+ 'provider' => 'oidc',
+ ]);
+});
+
+it('rejects new oidc users when neither normal nor provider registration is enabled', function () {
+ fakeOidcProvider(['email' => 'blocked@example.com']);
+
+ $response = $this->from('/login')->get(route('auth.callback', 'oidc'));
+
+ $response->assertRedirect('/login');
+ expect(User::whereEmail('blocked@example.com')->exists())->toBeFalse();
+});
+
+it('creates the root user when oidc provisions the first account', function () {
+ Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
+ OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
+
+ fakeOidcProvider(['email' => 'root@example.com', 'name' => 'Root User']);
+
+ $response = $this->get(route('auth.callback', 'oidc'));
+
+ $response->assertRedirect('/');
+ $this->assertDatabaseHas('users', ['id' => 0, 'email' => 'root@example.com']);
+ $this->assertDatabaseHas('team_user', ['team_id' => 0, 'user_id' => 0, 'role' => 'owner']);
+ expect(InstanceSettings::find(0)->is_registration_enabled)->toBeFalse();
+});
+
+it('persists raw claims as an array on the oauth identity', function () {
+ OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
+
+ fakeOidcProvider(['email' => 'claims@example.com']);
+
+ $this->get(route('auth.callback', 'oidc'))->assertRedirect('/');
+
+ $identity = OauthIdentity::where('email', 'claims@example.com')->first();
+ expect($identity->raw_claims)->toBeArray()
+ ->and($identity->raw_claims['sub'])->toBe('okta-user-1');
+});
+
+it('stores empty raw claims when the provider returns no user payload', function () {
+ OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
+
+ $user = (new OidcUser)->setIdTokenClaims([
+ 'iss' => 'https://idp.example.com',
+ 'sub' => 'okta-no-payload',
+ 'email_verified' => true,
+ ])->map([
+ 'id' => 'okta-no-payload',
+ 'name' => 'No Payload',
+ 'email' => 'nopayload@example.com',
+ ]);
+ $user->user = null;
+
+ $provider = Mockery::mock();
+ $provider->shouldReceive('setConfig')->andReturnSelf();
+ $provider->shouldReceive('user')->andReturn($user);
+ Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
+
+ $this->get(route('auth.callback', 'oidc'))->assertRedirect('/');
+
+ $identity = OauthIdentity::where('email', 'nopayload@example.com')->first();
+ expect($identity->raw_claims)->toBe([]);
+});
+
+it('rejects callbacks for disabled oidc provider', function () {
+ OauthSetting::where('provider', 'oidc')->update(['enabled' => false]);
+
+ $response = $this->from('/login')->get(route('auth.callback', 'oidc'));
+
+ $response->assertRedirect('/login');
+});
+
+it('logs callback failures with diagnostic context', function () {
+ Log::spy();
+
+ $provider = Mockery::mock();
+ $provider->shouldReceive('setConfig')->andReturnSelf();
+ $provider->shouldReceive('user')->andThrow(new RuntimeException('Token exchange failed'));
+ Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
+
+ $response = $this->from('/login')->get(route('auth.callback', ['provider' => 'oidc', 'code' => 'secret-code', 'state' => 'state-value']));
+
+ $response->assertRedirect('/login');
+ Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context) {
+ return $message === 'OAuth callback failed.'
+ && $context['provider'] === 'oidc'
+ && $context['exception_class'] === RuntimeException::class
+ && $context['exception_message'] === 'Token exchange failed'
+ && $context['has_code'] === true
+ && $context['has_state'] === true
+ && $context['exception'] instanceof RuntimeException;
+ });
+});
diff --git a/tests/Feature/PersistentStoragePerformanceTest.php b/tests/Feature/PersistentStoragePerformanceTest.php
index caad5822a1..6d1df54607 100644
--- a/tests/Feature/PersistentStoragePerformanceTest.php
+++ b/tests/Feature/PersistentStoragePerformanceTest.php
@@ -91,7 +91,7 @@ function createPerfApplicationWithVolumes(int $volumeCount = 5): array
return [$application, $firstVolume, $team];
}
-it('renders volume rows without nesting Livewire Show components', function () {
+it('renders volume rows inline without nested Livewire row components', function () {
[$application] = createPerfApplicationWithVolumes(5);
$html = Livewire::test(All::class, ['resource' => $application])->html();
@@ -100,7 +100,6 @@ it('renders volume rows without nesting Livewire Show components', function () {
->toContain('data-table')
->toContain('openBackupModal')
->toContain('wire:submit="submit(')
- ->not->toContain('livewire:project.shared.storages.show')
->not->toContain('shared-configure-volume-backup-');
});
diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php
index 91dcab3d26..a567a6e5c8 100644
--- a/tests/Feature/PersistentStorageVolumesLayoutTest.php
+++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php
@@ -43,6 +43,7 @@ it('keeps nested storage component keys stable when mounts are added or deleted'
->not->toContain('wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"');
});
+use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
@@ -138,7 +139,6 @@ function createApplicationWithVolume(array $applicationAttributes = [], array $v
it('renders volumes as a data table with shared column headers', function () {
$allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
- $showView = file_get_contents(resource_path('views/livewire/project/shared/storages/show.blade.php'));
$storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
expect($allView)
@@ -156,17 +156,10 @@ it('renders volumes as a data table with shared column headers', function () {
->toContain('data-table-row')
->toContain('volumes-mobile-label')
->not->toContain('table-badge table-badge-success')
- ->not->toContain('livewire:project.shared.storages.show')
->not->toContain('x-status-badge')
->not->toContain('font-mono')
->not->toContain('Service volume mounts are read-only here.');
- // Show remains available for isolated embeds/tests but is no longer nested from All.
- expect($showView)
- ->toContain('data-table-row')
- ->toContain('volumes-table-grid')
- ->not->toContain('font-mono');
-
// Service stack page: one settings-section card per compose service/resource.
expect($storageView)
->toContain('Str::headline($resource->name)')
@@ -193,7 +186,7 @@ it('renders volumes as a data table with shared column headers', function () {
->toMatch('/]*title="File-level consistency"[\s\S]*id="stopDuringBackup"[\s\S]*<\/x-callout>/');
expect(file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php')))
->toContain('Time ')
- ->toContain('x-forms.copy-button')
+ ->toContain('x-forms.copy-input')
->toContain('col-span-6');
$css = file_get_contents(resource_path('css/app.css'));
@@ -284,6 +277,62 @@ it('uses valid block wrappers around PR suffix helpers', function () {
->toBe(3);
});
+it('keeps bind mount source paths out of the add volume form', function () {
+ $storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
+ $volumesView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
+
+ expect($storageView)
+ ->not->toContain('id="host_path"')
+ ->not->toContain('Swarm Mode detected')
+ ->and($volumesView)
+ ->toMatch('/]*canGate="update"[^>]*:canResource="\$resource"/')
+ ->toContain('The next deployment will use a named Docker volume instead.')
+ ->toContain('Data from the existing host directory will not be copied to the named volume.');
+});
+
+it('creates named volumes without a host path in swarm mode', function () {
+ [$application] = createApplicationWithVolume();
+ $application->persistentStorages()->delete();
+
+ Livewire::test(Storage::class, ['resource' => $application])
+ ->set('isSwarm', true)
+ ->set('name', 'storage-app-data')
+ ->set('mount_path', '/data')
+ ->call('submitPersistentVolume')
+ ->assertHasNoErrors();
+
+ expect($application->persistentStorages()->first())
+ ->name->toBe($application->uuid.'-storage-app-data')
+ ->host_path->toBeNull();
+});
+
+it('uses a resource based default name for new volumes', function () {
+ [$application] = createApplicationWithVolume(['name' => 'Storage App']);
+
+ Livewire::test(Storage::class, ['resource' => $application])
+ ->assertSet('name', 'storage-app-data');
+});
+
+it('uses a valid fallback default volume name when the resource name has no slug characters', function () {
+ [$application] = createApplicationWithVolume(['name' => '---']);
+
+ Livewire::test(Storage::class, ['resource' => $application])
+ ->assertSet('name', 'volume-data');
+});
+
+it('removes existing bind mount source paths from the volume table', function () {
+ [$application, $volume] = createApplicationWithVolume(volumeAttributes: [
+ 'host_path' => '/srv/storage',
+ ]);
+
+ Livewire::test(All::class, ['resource' => $application])
+ ->assertSet("forms.{$volume->id}.hostPath", '/srv/storage')
+ ->call('clearHostPath', $volume->id)
+ ->assertHasNoErrors();
+
+ expect($volume->refresh()->host_path)->toBeNull();
+});
+
it('creates and exposes volume backups for service storage', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
@@ -443,16 +492,13 @@ it('hides PR deployment suffix for databases', function () {
});
it('uses a compact table badge for enabled backups instead of status-badge', function () {
- $showView = file_get_contents(resource_path('views/livewire/project/shared/storages/show.blade.php'));
+ $allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
- expect($showView)
- ->toContain('table-badge-success')
+ expect($allView)
+ ->toContain("'table-badge-success' => \$hasS3Backup")
->toContain('Volume backup is enabled')
->not->toContain('x-status-badge')
->not->toContain('status="Backup enabled"');
-
- // Badge label is the short "Backup" text, not the old pill-with-label that broke the input row.
- expect(preg_match('/table-badge-success[^>]*>\s*Backup\s*', $showView))->toBeGreaterThan(0);
});
it('gates file storage PR suffix markup behind git_based applications', function () {
diff --git a/tests/Feature/ProfileSsoIndicatorTest.php b/tests/Feature/ProfileSsoIndicatorTest.php
new file mode 100644
index 0000000000..0d48225eb5
--- /dev/null
+++ b/tests/Feature/ProfileSsoIndicatorTest.php
@@ -0,0 +1,91 @@
+create(['name' => 'Profile User']);
+
+ OauthIdentity::create([
+ 'user_id' => $user->id,
+ 'provider' => 'oidc',
+ 'issuer' => 'https://idp.example.com',
+ 'provider_user_id' => 'idp-user-1',
+ 'email' => $user->email,
+ ]);
+
+ $this->actingAs($user);
+
+ Livewire::test(ProfileIndex::class)
+ ->assertSee('Signed in with SSO')
+ ->assertSee('OIDC');
+});
+
+it('does not show sso status for password-only profile users', function () {
+ $user = User::factory()->create(['name' => 'Profile User']);
+
+ $this->actingAs($user);
+
+ Livewire::test(ProfileIndex::class)
+ ->assertDontSee('Signed in with SSO');
+});
+
+it('prevents sso linked users from opening or requesting profile email changes', function () {
+ $user = User::factory()->create(['name' => 'SSO User', 'email' => 'sso@example.com']);
+
+ OauthIdentity::create([
+ 'user_id' => $user->id,
+ 'provider' => 'oidc',
+ 'issuer' => 'https://idp.example.com',
+ 'provider_user_id' => 'idp-user-1',
+ 'email' => $user->email,
+ ]);
+
+ $this->actingAs($user);
+
+ Livewire::test(ProfileIndex::class)
+ ->assertSee('Email is managed by your SSO provider.')
+ ->call('showEmailChangeForm')
+ ->assertSet('show_email_change', false)
+ ->assertDispatched('error')
+ ->set('new_email', 'changed@example.com')
+ ->call('requestEmailChange')
+ ->assertSet('show_email_change', false)
+ ->assertSet('show_verification', false)
+ ->assertDispatched('error');
+
+ $user->refresh();
+
+ expect($user->email)->toBe('sso@example.com')
+ ->and($user->pending_email)->toBeNull()
+ ->and($user->email_change_code)->toBeNull()
+ ->and($user->email_change_code_expires_at)->toBeNull();
+});
+
+it('keeps profile email changes available for password-only users', function () {
+ config()->set('constants.coolify.self_hosted', false);
+ Notification::fake();
+
+ $user = User::factory()->create(['name' => 'Password User', 'email' => 'password@example.com']);
+
+ $this->actingAs($user);
+
+ Livewire::test(ProfileIndex::class)
+ ->call('showEmailChangeForm')
+ ->assertSet('show_email_change', true)
+ ->set('new_email', 'changed@example.com')
+ ->call('requestEmailChange')
+ ->assertSet('show_verification', true)
+ ->assertDispatched('success');
+
+ $user->refresh();
+
+ expect($user->pending_email)->toBe('changed@example.com')
+ ->and($user->email_change_code)->not->toBeNull();
+});
diff --git a/tests/Feature/Proxy/RestartProxyTest.php b/tests/Feature/Proxy/RestartProxyTest.php
index 16cddd36ea..0c393c03b5 100644
--- a/tests/Feature/Proxy/RestartProxyTest.php
+++ b/tests/Feature/Proxy/RestartProxyTest.php
@@ -1,16 +1,20 @@
withoutDefer();
InstanceSettings::forceCreate(['id' => 0]);
});
@@ -187,3 +191,20 @@ test('start proxy button shows a loading state while proxy startup actions run',
->assertSeeHtml('wire:loading.class="is-loading"')
->assertSeeHtml('wire:target="checkProxy,startProxy"');
});
+
+test('starting a proxy records a team audit event', function () {
+ [$user, $team, $server] = setupProxyUser('admin');
+ $activity = Activity::create([
+ 'description' => 'proxy start',
+ 'properties' => ['team_id' => $team->id],
+ ]);
+ StartProxy::shouldRun()->andReturn($activity);
+
+ $this->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ Livewire::test('server.navbar', ['server' => $server])
+ ->call('startProxy');
+
+ expect(AuditEvent::query()->sole()->event)->toBe('ui.proxy.started');
+});
diff --git a/tests/Feature/ProxyConfigurationLoadingStateTest.php b/tests/Feature/ProxyConfigurationLoadingStateTest.php
index 112f68d8cc..f5779e0ac5 100644
--- a/tests/Feature/ProxyConfigurationLoadingStateTest.php
+++ b/tests/Feature/ProxyConfigurationLoadingStateTest.php
@@ -10,3 +10,21 @@ it('disables proxy configuration controls and covers the editor while saving', f
->toContain('Updating proxy configuration')
->toContain('aria-live="polite"');
});
+
+it('loads only the compose file from the frontend and shows the shared loading indicator', function () {
+ $page = file_get_contents(resource_path('views/livewire/server/proxy/show.blade.php'));
+ $proxy = file_get_contents(resource_path('views/livewire/server/proxy.blade.php'));
+ $component = file_get_contents(app_path('Livewire/Server/Proxy.php'));
+
+ expect($page)
+ ->toContain(' ')
+ ->not->toContain(' ');
+
+ expect($proxy)
+ ->toContain('x-init="$wire.loadProxyConfiguration()"')
+ ->toContain('wire:loading.flex wire:target="loadProxyConfiguration"')
+ ->toContain(' ');
+
+ expect($component)
+ ->not->toContain('$this->loadProxyConfiguration();');
+});
diff --git a/tests/Feature/QueueApplicationDeploymentCommitTest.php b/tests/Feature/QueueApplicationDeploymentCommitTest.php
index ac6be5c9e9..8273a5c61c 100644
--- a/tests/Feature/QueueApplicationDeploymentCommitTest.php
+++ b/tests/Feature/QueueApplicationDeploymentCommitTest.php
@@ -3,17 +3,20 @@
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
+use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
+use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
beforeEach(function () {
+ $this->withoutDefer();
Bus::fake([ApplicationDeploymentJob::class]);
$this->team = Team::factory()->create();
@@ -42,6 +45,59 @@ function makeApplication(int $environmentId, int $destinationId, ?string $gitCom
}
describe('queue_application_deployment commit resolution', function () {
+ test('records a team audit event when a user queues a deployment', function () {
+ $user = User::factory()->create();
+ $this->team->members()->attach($user, ['role' => 'owner']);
+ $this->actingAs($user);
+ session(['currentTeam' => $this->team]);
+ $application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
+
+ queue_application_deployment($application, 'audit-deploy-uuid');
+
+ $this->assertDatabaseHas('audit_events', [
+ 'team_id' => $this->team->id,
+ 'event' => 'ui.application.deployed',
+ 'resource_uuid' => $application->uuid,
+ ]);
+ });
+
+ test('uses the deployed application team for the audit event', function () {
+ $actorTeam = Team::factory()->create();
+ $user = User::factory()->create();
+ $actorTeam->members()->attach($user, ['role' => 'owner']);
+ $this->actingAs($user);
+ session()->forget('currentTeam');
+ $application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
+
+ queue_application_deployment($application, 'resource-team-audit-deploy');
+
+ $event = AuditEvent::query()->where('event', 'ui.application.deployed')->sole();
+
+ expect($event->team_id)->toBe($this->team->id)
+ ->and($event->team_id)->not->toBe($actorTeam->id)
+ ->and($event->resource_uuid)->toBe($application->uuid)
+ ->and($event->metadata)->not->toHaveKey('team_id');
+ });
+
+ test('records only the rollback audit event when a user queues a rollback', function () {
+ $user = User::factory()->create();
+ $this->team->members()->attach($user, ['role' => 'owner']);
+ $this->actingAs($user);
+ $application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
+ AuditEvent::query()->delete();
+
+ queue_application_deployment(
+ application: $application,
+ deployment_uuid: 'audit-rollback-uuid',
+ commit: 'previous-commit',
+ rollback: true,
+ );
+
+ expect(AuditEvent::query()->pluck('event')->all())->toBe([
+ 'ui.application.rollback',
+ ]);
+ });
+
test('uses application git_commit_sha when commit parameter omitted', function () {
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
diff --git a/tests/Feature/ResourceDetailsVisibilityTest.php b/tests/Feature/ResourceDetailsVisibilityTest.php
index 29f611cbaa..e11c86fe99 100644
--- a/tests/Feature/ResourceDetailsVisibilityTest.php
+++ b/tests/Feature/ResourceDetailsVisibilityTest.php
@@ -27,31 +27,29 @@ it('keeps the resource details helper text visible below the modal header', func
])->render();
expect($html)
- ->toContain('Identifiers for this resource. Read-only')
+ ->toContain('readonly')
->toContain('pt-1')
->not->toContain('-mt-4');
});
it('renders copy fields as visible readonly controls with an accessible copy action', function () {
- $html = Blade::render(' ');
+ $html = Blade::render(' ');
expect($html)
->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"')
->toContain('readonly')
- ->toContain('window.copyToClipboard')
+ ->toContain('x-data="copyButton"')
->toContain('input-with-copy-button')
- ->toContain('copy-button')
->toContain('aria-label="Copy to clipboard"')
- ->toContain('title="Copy to clipboard"')
- ->toContain('class="size-[18px] text-green-500"');
+ ->toContain('title="Copy to clipboard"');
});
-it('uses the shared copy field for newly issued api tokens', function () {
+it('uses the shared copy button for newly issued api tokens', function () {
$blade = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php'));
expect($blade)
- ->toContain(' ')
- ->not->toContain('navigator.clipboard.writeText(@js(session(\'token\')))');
+ ->toContain('not->toContain('navigator.clipboard');
});
it('keeps copy button padding above settings-workspace input overrides', function () {
diff --git a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php
index 7b3f825758..01823ad6cd 100644
--- a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php
+++ b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php
@@ -181,14 +181,14 @@ it('keeps advanced operations in a dedicated Advanced dropdown', function () {
expect(substr_count($application, 'resource-heading-navbar'))->toBe(1);
});
-it('groups desktop application lifecycle controls in one Actions dropdown', function () {
+it('groups desktop application lifecycle controls in one split control', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
expect($desktop)
->toContain('application-desktop-actions')
- ->toContain('Actions')
- ->toContain('listbox-panel top-full! right-0! left-auto!')
+ ->toContain('toContain('toContain('listbox-option')
->toContain('Deploy')
->toContain('Restart')
@@ -235,8 +235,8 @@ it('groups service restart options in the Actions dropdown', function () {
$mobile = str($heading)->before("@teleport('#resource-action-hud-slot')")->toString();
expect($mobile)
- ->toContain('Restart current version')
- ->toContain('Pull latest and restart')
+ ->toContain('Restart')
+ ->toContain('Restart (pull latest)')
->not->toContain('Pull Latest Images & Restart');
});
@@ -250,21 +250,17 @@ it('orders service restart actions before stop and advanced operations', functio
->toBeLessThan(strpos($actions, 'Stop'));
});
-it('groups application lifecycle options in an Actions dropdown', function () {
+it('promotes the primary application action in the desktop split control', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
- $trigger = str($desktop)->after('id="application-desktop-actions"')->before('toString();
expect($desktop)
->toContain('id="application-desktop-actions"')
- ->toContain('Actions')
+ ->toContain('
')
->toContain('Deploy')
->toContain('Deploy (without cache)')
->toContain('force_deploy_without_cache')
- ->toContain('deploy(true)')
- ->and($trigger)
- ->toContain('button button-highlighted')
- ->not->toContain('play-circle');
+ ->toContain('deploy(true)');
$mobile = str($heading)->before("@teleport('#resource-action-hud-slot')")->toString();
@@ -314,27 +310,28 @@ it('uses the shared Coollabs gradient for primary resource actions in the deskto
->toContain('@apply button-highlighted;');
});
-it('uses iconless highlighted Actions dropdowns for service and proxy lifecycle controls', function () {
+it('promotes a primary action for service and proxy lifecycle controls', function () {
foreach ([
resource_path('views/livewire/project/service/heading.blade.php') => 'service-desktop-actions',
resource_path('views/livewire/server/navbar.blade.php') => 'server-desktop-actions',
] as $path => $id) {
$heading = file_get_contents($path);
- $trigger = str($heading)->after("id=\"{$id}\"")->before('toString();
+ $control = str($heading)->after("id=\"{$id}\"")->before('')->toString();
- expect($trigger)
- ->toContain('button button-highlighted')
- ->toContain('Actions')
- ->not->toContain('play-circle');
+ expect($control)
+ ->toContain('
toContain('listbox-option');
}
});
-it('places the Traefik dashboard last in the server Actions menu', function () {
+it('links the Traefik dashboard beside the server actions instead of inside the dropdown', function () {
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
- $actions = str($navbar)->after('id="server-desktop-actions"')->before('@endcan')->toString();
+ $desktopActions = str($navbar)->after('id="server-desktop-actions"')->before('')->toString();
+ $mobileActions = str($navbar)->after('id="server-mobile-actions"')->before('')->toString();
- expect(strpos($actions, 'Refresh Proxy Status'))
- ->toBeLessThan(strpos($actions, 'Traefik Dashboard'));
+ expect($desktopActions)->not->toContain('Traefik')
+ ->and($mobileActions)->not->toContain('Traefik')
+ ->and($navbar)->toContain('Traefik Dashboard');
});
it('keeps database lifecycle controls direct and highlights the primary action', function () {
@@ -342,10 +339,10 @@ it('keeps database lifecycle controls direct and highlights the primary action',
$desktop = str($heading)->after('resource-heading-actions flex')->before('@endteleport')->toString();
expect($desktop)
+ ->toContain('toContain('toContain('Restart')
->toContain('Stop')
- ->toContain('button button-highlighted')
- ->not->toContain('not->toContain('toContain("'label' => 'Terminal'")
->toContain("'label' => 'Deployment Logs'")
->toContain("'label' => 'Runtime Logs'")
- ->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics']")
+ ->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics', 'Analytics']")
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback'");
});
@@ -581,9 +578,9 @@ it('fits the combined deployment history and logs within the desktop viewport',
expect($indexClass)->toContain('$this->defaultTake = 3;')
->and($showView)
- ->toContain('xl:h-[calc(100dvh-7.5rem)]')
- ->toContain('xl:overflow-hidden')
- ->toContain('xl:flex-1');
+ ->toContain('min-h-[calc(100dvh-7.5rem)]')
+ ->toContain('xl:h-[32rem] xl:min-h-0 xl:flex-none')
+ ->toContain('overflow-hidden');
});
it('uses only the healthcheck toggle action to communicate enabled state', function () {
@@ -720,3 +717,11 @@ it('uses overflow scroll arrows on resource heading navbars', function () {
expect(file_get_contents($path))->toContain('toContain("'chevron-down' => 'not->toContain('chevron-down\' => \'withoutDefer();
+ config(['app.maintenance.driver' => 'file']);
+ InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0, 'is_api_enabled' => true]));
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+ $this->bearerToken = $this->user->createToken('secret-manager-api-test', ['*'])->plainTextToken;
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $this->application = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ]);
+});
+
+function secretManagerApiHeaders(string $token): array
+{
+ return ['Authorization' => 'Bearer '.$token];
+}
+
+test('a secret manager integration token can be created through the api', function () {
+ Http::fake(['https://api.doppler.com/v3/me' => Http::response([], 200)]);
+
+ $response = $this->withHeaders(secretManagerApiHeaders($this->bearerToken))
+ ->postJson('/api/v1/security/integration-tokens', [
+ 'provider' => 'doppler',
+ 'name' => 'Production secrets',
+ 'token' => 'dp.st.secret',
+ ])
+ ->assertCreated()
+ ->assertJsonStructure(['uuid']);
+
+ $token = IntegrationToken::query()->whereUuid($response->json('uuid'))->firstOrFail();
+
+ expect($token->team_id)->toBe($this->team->id)
+ ->and($token->capabilities)->toBe(['secrets']);
+
+ $this->assertDatabaseHas('audit_events', [
+ 'team_id' => $this->team->id,
+ 'event' => 'api.integration_token.created',
+ 'resource_uuid' => $token->uuid,
+ ]);
+});
+
+test('secret manager provider base urls only accept http and https', function (string $provider, array $metadata) {
+ Http::fake();
+
+ $this->withHeaders(secretManagerApiHeaders($this->bearerToken))
+ ->postJson('/api/v1/security/integration-tokens', [
+ 'provider' => $provider,
+ 'name' => 'Invalid base URL',
+ 'token' => 'token',
+ 'metadata' => $metadata,
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors('metadata.base_url');
+
+ Http::assertNothingSent();
+})->with([
+ 'infisical' => ['infisical', ['base_url' => 'ftp://infisical.example.com', 'client_id' => 'client-1']],
+ 'vault' => ['vault', ['base_url' => 'ftp://vault.example.com']],
+]);
+
+test('an application can be configured to use a secret manager through the api', function () {
+ $token = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'doppler',
+ 'name' => 'Production secrets',
+ 'token' => 'dp.sa.secret',
+ 'capabilities' => ['secrets'],
+ ]);
+
+ $this->withHeaders(secretManagerApiHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}/secret-manager", [
+ 'integration_token_uuid' => $token->uuid,
+ 'settings' => [
+ 'project' => 'website',
+ 'config' => 'production',
+ ],
+ ])
+ ->assertOk()
+ ->assertJsonPath('integration_token_uuid', $token->uuid)
+ ->assertJsonPath('provider', 'doppler')
+ ->assertJsonPath('settings.project', 'website');
+
+ $link = $this->application->secretManagerLink()->firstOrFail();
+
+ expect($link->integration_token_id)->toBe($token->id)
+ ->and($link->settings)->toBe(['project' => 'website', 'config' => 'production']);
+
+ $this->assertDatabaseHas('audit_events', [
+ 'team_id' => $this->team->id,
+ 'event' => 'api.application.secret_manager.updated',
+ 'resource_uuid' => $this->application->uuid,
+ ]);
+});
diff --git a/tests/Feature/SecretManagerLinkMigrationTest.php b/tests/Feature/SecretManagerLinkMigrationTest.php
new file mode 100644
index 0000000000..f79b1ef948
--- /dev/null
+++ b/tests/Feature/SecretManagerLinkMigrationTest.php
@@ -0,0 +1,16 @@
+filter(fn (array $index): bool => $index['columns'] === ['resourceable_type', 'resourceable_id'])
+ ->values();
+
+ expect($resourceableIndexes)
+ ->toHaveCount(1)
+ ->and($resourceableIndexes->first()['unique'])->toBeTrue();
+});
diff --git a/tests/Feature/SecretManagers/SecretManagerLinkTest.php b/tests/Feature/SecretManagers/SecretManagerLinkTest.php
new file mode 100644
index 0000000000..bbf1924dfe
--- /dev/null
+++ b/tests/Feature/SecretManagers/SecretManagerLinkTest.php
@@ -0,0 +1,471 @@
+whereKey(0)->exists()) {
+ $settings = new InstanceSettings;
+ $settings->id = 0;
+ $settings->save();
+ }
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+ $this->actingAs($this->user);
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $destination = $server->standaloneDockers()->firstOrFail();
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ $this->application = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ]);
+});
+
+function createSecretManagerLink(string $provider, array $settings = [], array $metadata = []): SecretManagerLink
+{
+ $token = IntegrationToken::query()->create([
+ 'team_id' => test()->team->id,
+ 'provider' => $provider,
+ 'name' => ucfirst($provider).' token',
+ 'token' => 'the-secret-token',
+ 'capabilities' => ['secrets'],
+ 'metadata' => $metadata ?: null,
+ ]);
+
+ return test()->application->secretManagerLink()->create([
+ 'integration_token_id' => $token->id,
+ 'settings' => $settings ?: null,
+ ]);
+}
+
+function makeDeploymentJobForSecrets(): ApplicationDeploymentJob
+{
+ $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
+
+ $queue = ApplicationDeploymentQueue::create([
+ 'application_id' => test()->application->id,
+ 'deployment_uuid' => 'secrets-test-'.fake()->uuid(),
+ 'status' => 'in_progress',
+ 'server_id' => test()->application->destination->server->id,
+ 'destination_id' => test()->application->destination->id,
+ 'commit' => 'HEAD',
+ 'pull_request_id' => 0,
+ ]);
+
+ $properties = [
+ 'application' => test()->application,
+ 'application_deployment_queue' => $queue,
+ 'mainServer' => test()->application->destination->server,
+ 'pull_request_id' => 0,
+ ];
+
+ foreach ($properties as $property => $value) {
+ $reflection = new ReflectionProperty($job, $property);
+ $reflection->setValue($job, $value);
+ }
+
+ return $job;
+}
+
+function resolveEnvOnJob(ApplicationDeploymentJob $job, $env): ?string
+{
+ return (new ReflectionMethod($job, 'resolve_environment_variable'))->invoke($job, $env);
+}
+
+function deploymentHasRemoteBuildtimeReferences(ApplicationDeploymentJob $job): bool
+{
+ return (new ReflectionMethod($job, 'has_remote_buildtime_secret_references'))->invoke($job);
+}
+
+test('remote build-time secret references prevent same-commit image reuse', function (string $reference) {
+ $this->application->environment_variables()->create([
+ 'key' => 'BUILD_SECRET',
+ 'value' => $reference,
+ 'is_buildtime' => true,
+ ]);
+
+ expect(deploymentHasRemoteBuildtimeReferences(makeDeploymentJobForSecrets()))->toBeTrue();
+})->with([
+ 'provider-neutral reference' => '{{vault.BUILD_SECRET}}',
+]);
+
+test('runtime-only remote secret references still allow same-commit image reuse', function () {
+ $this->application->environment_variables()->create([
+ 'key' => 'RUNTIME_SECRET',
+ 'value' => '{{vault.RUNTIME_SECRET}}',
+ 'is_buildtime' => false,
+ ]);
+
+ expect(deploymentHasRemoteBuildtimeReferences(makeDeploymentJobForSecrets()))->toBeFalse();
+});
+
+test('a doppler link fetches secrets with the stored token', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'DB_PASSWORD' => 's3cret',
+ ]),
+ ]);
+
+ $link = createSecretManagerLink('doppler', ['project' => 'proj', 'config' => 'prd']);
+
+ expect($link->fetchSecrets())->toBe(['DB_PASSWORD' => 's3cret']);
+
+ Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer the-secret-token')
+ && str_contains($request->url(), 'project=proj'));
+});
+
+test('a vault link uses the base url and namespace from the token metadata', function () {
+ Http::fake([
+ 'https://example.com:8200/v1/kv/data/apps/web' => Http::response([
+ 'data' => ['data' => ['KEY' => 'value']],
+ ]),
+ ]);
+
+ $link = createSecretManagerLink('vault',
+ ['mount' => 'kv', 'path' => 'apps/web'],
+ ['base_url' => 'https://example.com:8200', 'namespace' => 'team-a'],
+ );
+
+ expect($link->fetchSecrets())->toBe(['KEY' => 'value']);
+
+ Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'team-a'));
+});
+
+test('services resolve environment variables from their configured secret manager', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'API_KEY' => 'remote-service-value',
+ ]),
+ ]);
+
+ $service = Service::factory()->create([
+ 'environment_id' => $this->application->environment_id,
+ 'destination_id' => $this->application->destination_id,
+ 'destination_type' => $this->application->destination_type,
+ ]);
+ $token = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'doppler',
+ 'name' => 'Service secrets',
+ 'token' => 'the-secret-token',
+ 'capabilities' => ['secrets'],
+ ]);
+ $service->secretManagerLink()->create(['integration_token_id' => $token->id]);
+ $environmentVariable = $service->environment_variables()->create([
+ 'key' => 'API_KEY',
+ 'value' => '{{vault.API_KEY}}',
+ ]);
+
+ expect($service->resolveSecretManagerEnvironmentVariable($environmentVariable))->toBe('remote-service-value');
+});
+
+test('redis remote credentials stay deployment-local and use raw values in the start command', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'REDIS_PASSWORD' => 'p4$$word',
+ 'REDIS_USERNAME' => 'remote-user',
+ ]),
+ ]);
+
+ $redis = StandaloneRedis::forceCreate([
+ 'uuid' => 'redis-secret-test',
+ 'name' => 'Redis secret test',
+ 'image' => 'redis:7-alpine',
+ 'environment_id' => $this->application->environment_id,
+ 'destination_id' => $this->application->destination_id,
+ 'destination_type' => $this->application->destination_type,
+ ]);
+ $token = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'doppler',
+ 'name' => 'Redis secrets',
+ 'token' => 'the-secret-token',
+ 'capabilities' => ['secrets'],
+ ]);
+ $redis->secretManagerLink()->create(['integration_token_id' => $token->id]);
+ $sharedPassword = SharedEnvironmentVariable::query()->create([
+ 'key' => 'REDIS_PASSWORD',
+ 'value' => '{{vault.REDIS_PASSWORD}}',
+ 'type' => 'team',
+ 'team_id' => $this->team->id,
+ ]);
+ $password = $redis->runtime_environment_variables()->create([
+ 'key' => 'REDIS_PASSWORD',
+ 'value' => '{{team.REDIS_PASSWORD}}',
+ ]);
+ $username = $redis->runtime_environment_variables()->create([
+ 'key' => 'REDIS_USERNAME',
+ 'value' => '{{vault.REDIS_USERNAME}}',
+ ]);
+
+ $action = new StartRedis;
+ $action->database = $redis;
+ $environmentVariables = (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
+ $startCommand = (new ReflectionMethod($action, 'buildStartCommand'))->invoke($action);
+
+ expect($password->fresh()->value)->toBe('{{team.REDIS_PASSWORD}}')
+ ->and($sharedPassword->fresh()->value)->toBe('{{vault.REDIS_PASSWORD}}')
+ ->and($username->fresh()->value)->toBe('{{vault.REDIS_USERNAME}}')
+ ->and($environmentVariables)->toContain('REDIS_PASSWORD=p4$$word')
+ ->and($environmentVariables)->toContain('REDIS_USERNAME=remote-user')
+ ->and($startCommand)->toContain('--requirepass p4$$word');
+});
+
+test('all deployable environment-variable resources support secret managers', function (string $resourceClass) {
+ expect(class_uses_recursive($resourceClass))->toContain(HasSecretManager::class);
+})->with([
+ Application::class,
+ Service::class,
+ StandalonePostgresql::class,
+ StandaloneMysql::class,
+ StandaloneMariadb::class,
+ StandaloneMongodb::class,
+ StandaloneRedis::class,
+ StandaloneKeydb::class,
+ StandaloneDragonfly::class,
+ StandaloneClickhouse::class,
+]);
+
+test('an application has at most one secret manager source', function () {
+ createSecretManagerLink('doppler');
+
+ $secondToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'vault',
+ 'name' => 'Vault token',
+ 'token' => 'other-token',
+ 'capabilities' => ['secrets'],
+ 'metadata' => ['base_url' => 'https://vault.internal:8200'],
+ ]);
+
+ expect(fn () => $this->application->secretManagerLink()->create([
+ 'integration_token_id' => $secondToken->id,
+ ]))->toThrow(QueryException::class);
+});
+
+test('a secret reference is substituted at deploy time and formatted as a dotenv literal', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'DB_PASSWORD' => 'p4$$word',
+ ]),
+ ]);
+
+ createSecretManagerLink('doppler');
+
+ $env = $this->application->environment_variables()->create([
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgres://app:{{vault.DB_PASSWORD}}@db:5432/app',
+ ]);
+
+ $job = makeDeploymentJobForSecrets();
+
+ expect(resolveEnvOnJob($job, $env))->toBe("'postgres://app:p4\$\$word@db:5432/app'");
+});
+
+test('provider alias references resolve against the single source', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'API_KEY' => 'abc',
+ ]),
+ ]);
+
+ createSecretManagerLink('doppler');
+
+ $env = $this->application->environment_variables()->create([
+ 'key' => 'API_KEY',
+ 'value' => '{{vault.API_KEY}}',
+ ]);
+
+ expect(resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))->toBe("'abc'");
+});
+
+test('the fetch happens once per deployment even with many references', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'A' => '1',
+ 'B' => '2',
+ ]),
+ ]);
+
+ createSecretManagerLink('doppler');
+
+ $first = $this->application->environment_variables()->create(['key' => 'A', 'value' => '{{vault.A}}']);
+ $second = $this->application->environment_variables()->create(['key' => 'B', 'value' => '{{vault.B}}']);
+
+ $job = makeDeploymentJobForSecrets();
+ resolveEnvOnJob($job, $first);
+ resolveEnvOnJob($job, $second);
+
+ Http::assertSentCount(1);
+});
+
+test('variables without references never contact the secret manager', function () {
+ Http::fake();
+
+ createSecretManagerLink('doppler');
+
+ $env = $this->application->environment_variables()->create([
+ 'key' => 'PLAIN',
+ 'value' => 'plain-value',
+ ]);
+
+ expect(resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))->toBe('plain-value');
+
+ Http::assertNothingSent();
+});
+
+test('a null environment variable value remains null', function () {
+ $env = $this->application->environment_variables()->create([
+ 'key' => 'EMPTY',
+ 'value' => null,
+ ]);
+
+ expect($this->application->resolveSecretManagerEnvironmentVariable($env))->toBeNull();
+});
+
+test('a missing secret key fails the deployment and names the variable', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'OTHER' => 'value',
+ ]),
+ ]);
+
+ createSecretManagerLink('doppler');
+
+ $env = $this->application->environment_variables()->create([
+ 'key' => 'DB_PASSWORD',
+ 'value' => '{{vault.GONE_KEY}}',
+ ]);
+
+ expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
+ ->toThrow(DeploymentException::class, 'Missing secret keys: GONE_KEY (referenced by DB_PASSWORD).');
+});
+
+test('a reference without a configured source fails the deployment', function () {
+ Http::fake();
+
+ $env = $this->application->environment_variables()->create([
+ 'key' => 'DB_PASSWORD',
+ 'value' => '{{vault.DB_PASSWORD}}',
+ ]);
+
+ expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
+ ->toThrow(DeploymentException::class, 'no secret manager source is configured');
+
+ Http::assertNothingSent();
+});
+
+test('a fetch failure stops the deployment with a clear error', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'messages' => ['Invalid Auth token'],
+ ], 401),
+ ]);
+
+ createSecretManagerLink('doppler');
+
+ $env = $this->application->environment_variables()->create([
+ 'key' => 'DB_PASSWORD',
+ 'value' => '{{vault.DB_PASSWORD}}',
+ ]);
+
+ expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
+ ->toThrow(DeploymentException::class, 'Could not fetch secrets from Doppler.');
+});
+
+test('import creates reference variables for missing keys only', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'EXISTING' => 'value-a',
+ 'NEW_KEY' => 'value-b',
+ ]),
+ ]);
+
+ createSecretManagerLink('doppler');
+ $this->application->environment_variables()->create(['key' => 'EXISTING', 'value' => 'local']);
+
+ $imported = $this->application->secretManagerLink->importMissingReferences();
+
+ expect($imported)->toBe(['NEW_KEY']);
+
+ $created = $this->application->environment_variables()->where('key', 'NEW_KEY')->firstOrFail();
+ expect($created->value)->toBe('{{vault.NEW_KEY}}')
+ ->and($this->application->environment_variables()->where('key', 'EXISTING')->firstOrFail()->value)->toBe('local');
+});
+
+test('secret references are not marked as shared variables', function () {
+ $secretRef = $this->application->environment_variables()->create([
+ 'key' => 'A',
+ 'value' => '{{vault.A}}',
+ ]);
+ $sharedRef = $this->application->environment_variables()->create([
+ 'key' => 'B',
+ 'value' => '{{team.B}}',
+ ]);
+
+ expect($secretRef->refresh()->is_shared)->toBeFalse()
+ ->and($sharedRef->refresh()->is_shared)->toBeTrue();
+});
+
+test('remote secret values are formatted as dotenv literals', function () {
+ $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
+ $format = fn (string $value) => (new ReflectionMethod($job, 'format_remote_secret_value'))->invoke($job, $value);
+
+ expect($format('simple'))->toBe("'simple'")
+ ->and($format('with $dollar and spaces'))->toBe("'with \$dollar and spaces'")
+ ->and($format("it's quoted"))->toBe('"it\'s quoted"')
+ ->and($format('{"json": true}'))->toBe('\'{"json": true}\'');
+});
+
+test('deleting an integration token is blocked while links exist', function () {
+ $link = createSecretManagerLink('doppler');
+
+ Livewire\Livewire::test(IntegrationTokens::class)
+ ->call('deleteToken', $link->integration_token_id)
+ ->assertDispatched('error');
+
+ expect(IntegrationToken::query()->whereKey($link->integration_token_id)->exists())->toBeTrue();
+
+ $link->delete();
+
+ Livewire\Livewire::test(IntegrationTokens::class)
+ ->call('deleteToken', $link->integration_token_id)
+ ->assertDispatched('success');
+
+ expect(IntegrationToken::query()->whereKey($link->integration_token_id)->exists())->toBeFalse();
+});
diff --git a/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php b/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php
new file mode 100644
index 0000000000..28a8707b43
--- /dev/null
+++ b/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php
@@ -0,0 +1,317 @@
+withoutDefer();
+ if (! InstanceSettings::query()->whereKey(0)->exists()) {
+ $settings = new InstanceSettings;
+ $settings->id = 0;
+ $settings->save();
+ }
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+ $this->actingAs($this->user);
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $destination = $server->standaloneDockers()->firstOrFail();
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ $this->application = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ]);
+
+ $this->token = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'doppler',
+ 'name' => 'Doppler production',
+ 'token' => 'dp.st.token',
+ 'capabilities' => ['secrets'],
+ ]);
+});
+
+test('selecting a token in the dropdown saves the source automatically', function () {
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->set('integration_token_uuid', $this->token->uuid)
+ ->assertDispatched('success');
+
+ $this->assertDatabaseHas('secret_manager_links', [
+ 'resourceable_type' => $this->application->getMorphClass(),
+ 'resourceable_id' => $this->application->id,
+ 'integration_token_id' => $this->token->id,
+ ]);
+ $this->assertDatabaseHas('audit_events', [
+ 'team_id' => $this->team->id,
+ 'event' => 'ui.application.secret_manager.source_updated',
+ 'resource_uuid' => $this->application->uuid,
+ ]);
+});
+
+test('service account settings are required and save automatically on blur', function () {
+ $serviceAccountToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'doppler',
+ 'name' => 'Doppler service account',
+ 'token' => 'dp.sa.token',
+ 'capabilities' => ['secrets'],
+ ]);
+
+ $this->application->secretManagerLink()->create(['integration_token_id' => $serviceAccountToken->id]);
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->call('saveSettings')
+ ->assertHasErrors(['settings.project', 'settings.config']);
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->set('settings', ['project' => 'proj', 'config' => 'prd'])
+ ->call('saveSettings')
+ ->assertHasNoErrors()
+ ->assertDispatched('success');
+
+ expect($this->application->secretManagerLink()->firstOrFail()->settings)
+ ->toBe(['project' => 'proj', 'config' => 'prd']);
+});
+
+test('doppler settings match the selected token type', function () {
+ $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->assertSee('Project and config are fixed by this service token.')
+ ->assertDontSee('Project (required)');
+
+ $serviceAccountToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'doppler',
+ 'name' => 'Doppler service account',
+ 'token' => 'dp.sa.token',
+ 'capabilities' => ['secrets'],
+ ]);
+
+ $this->application->secretManagerLink()->update([
+ 'integration_token_id' => $serviceAccountToken->id,
+ ]);
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->assertSee('Project (required)')
+ ->assertSee('Config (required)');
+});
+
+test('selecting another token replaces the source and clears provider settings without checking references', function () {
+ $this->application->secretManagerLink()->create([
+ 'integration_token_id' => $this->token->id,
+ 'settings' => ['project' => 'proj'],
+ ]);
+ $this->application->environment_variables()->create(['key' => 'A', 'value' => '{{vault.A}}']);
+
+ $otherToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'vault',
+ 'name' => 'Vault',
+ 'token' => 'hvs.token',
+ 'capabilities' => ['secrets'],
+ 'metadata' => ['base_url' => 'https://vault.internal:8200'],
+ ]);
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->set('integration_token_uuid', $otherToken->uuid)
+ ->assertDispatched('success')
+ ->assertSet('settings', []);
+
+ $this->assertDatabaseCount('secret_manager_links', 1);
+ $this->assertDatabaseHas('secret_manager_links', [
+ 'integration_token_id' => $otherToken->id,
+ 'settings' => null,
+ ]);
+
+ Http::assertNothingSent();
+});
+
+test('browse keys shows key names only and search filters them', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'DB_PASSWORD' => 'super-secret-value',
+ 'API_KEY' => 'another-secret',
+ ]),
+ ]);
+
+ $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
+
+ $component = Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->call('loadKeys')
+ ->assertSee('DB_PASSWORD')
+ ->assertSee('API_KEY')
+ ->assertSee('{{vault.DB_PASSWORD}}')
+ ->assertSeeHtml('class="flex min-w-0 flex-col"')
+ ->assertDontSee('{{ $key }}')
+ ->assertDontSee('super-secret-value')
+ ->assertDontSee('another-secret');
+
+ expect($component->get('keys'))->toBe(['API_KEY', 'DB_PASSWORD']);
+
+ $auditEvent = AuditEvent::query()->where('event', 'ui.application.secret_manager.keys_viewed')->sole();
+ expect($auditEvent->metadata['key_count'])->toBe(2)
+ ->and($auditEvent->metadata)->not->toHaveKey('keys');
+
+ $component->set('search', 'db_pass')
+ ->assertSee('DB_PASSWORD')
+ ->assertDontSee('API_KEY');
+});
+
+test('browse key actions encode apostrophes and backslashes', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ "TEAM'S_KEY" => 'apostrophe-secret',
+ 'TEAM\\KEY' => 'backslash-secret',
+ ]),
+ ]);
+
+ $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
+
+ $apostropheExpression = 'addReference('.Js::from("TEAM'S_KEY").')';
+ $backslashExpression = 'addReference('.Js::from('TEAM\\KEY').')';
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->call('loadKeys')
+ ->assertSeeHtml('wire:click="'.$apostropheExpression.'"')
+ ->assertSeeHtml('wire:target="'.$apostropheExpression.'"')
+ ->assertSeeHtml('wire:click="'.$backslashExpression.'"')
+ ->assertSeeHtml('wire:target="'.$backslashExpression.'"');
+});
+
+test('add reference creates a variable with a secret reference value', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'DB_PASSWORD' => 'super-secret-value',
+ ]),
+ ]);
+
+ $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->call('loadKeys')
+ ->call('addReference', 'DB_PASSWORD')
+ ->assertDispatched('refreshEnvs')
+ ->assertDispatched('success');
+
+ $created = $this->application->environment_variables()->where('key', 'DB_PASSWORD')->firstOrFail();
+ expect($created->value)->toBe('{{vault.DB_PASSWORD}}');
+
+ $auditEvent = AuditEvent::query()->where('event', 'ui.application.secret_manager.reference_created')->sole();
+ expect($auditEvent->metadata['secret_key'])->toBe('[REDACTED]');
+});
+
+test('import all creates references for missing keys and skips existing ones', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'EXISTING' => 'a',
+ 'NEW_KEY' => 'b',
+ ]),
+ ]);
+
+ $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
+ $this->application->environment_variables()->create(['key' => 'EXISTING', 'value' => 'local']);
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->call('importAll')
+ ->assertDispatched('refreshEnvs')
+ ->assertDispatched('success');
+
+ expect($this->application->environment_variables()->where('key', 'NEW_KEY')->firstOrFail()->value)
+ ->toBe('{{vault.NEW_KEY}}')
+ ->and($this->application->environment_variables()->where('key', 'EXISTING')->firstOrFail()->value)
+ ->toBe('local');
+
+ $auditEvent = AuditEvent::query()->where('event', 'ui.application.secret_manager.references_imported')->sole();
+ expect($auditEvent->metadata['key_count'])->toBe(1)
+ ->and($auditEvent->metadata['secret_keys'])->toBe('[REDACTED]');
+});
+
+test('the source can be removed', function () {
+ $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->call('removeSource')
+ ->assertDispatched('success');
+
+ $this->assertDatabaseCount('secret_manager_links', 0);
+ $this->assertDatabaseHas('audit_events', [
+ 'team_id' => $this->team->id,
+ 'event' => 'ui.application.secret_manager.source_removed',
+ 'resource_uuid' => $this->application->uuid,
+ ]);
+});
+
+test('members without update permission cannot save a source', function () {
+ $member = User::factory()->create();
+ $this->team->members()->attach($member->id, ['role' => 'member']);
+ $this->actingAs($member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
+ ->set('integration_token_uuid', $this->token->uuid)
+ ->assertDispatched('error', 'You need at least admin or owner permissions to update this application.');
+
+ $this->assertDatabaseCount('secret_manager_links', 0);
+});
+
+test('the edit modal value autocomplete offers the vault scope with lazy key fetch', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'DB_PASSWORD' => 'super-secret-value',
+ ]),
+ ]);
+
+ $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
+ $env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
+
+ $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
+ ->call('loadValues')
+ ->assertSeeHtml('hasVaultSource: true');
+
+ expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']);
+});
+
+test('the edit modal value autocomplete reports secret provider failures', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([], 503),
+ ]);
+
+ $this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
+ $env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
+ $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']);
+
+ expect(fn () => $component->instance()->fetchSecretManagerKeys())
+ ->toThrow(RuntimeException::class, 'Unable to fetch secret manager keys.');
+});
+
+test('the edit modal value autocomplete has no vault scope without a source', function () {
+ $env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
+
+ $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
+ ->call('loadValues')
+ ->assertSeeHtml('hasVaultSource: false');
+
+ expect($component->instance()->fetchSecretManagerKeys())->toBe([]);
+});
diff --git a/tests/Feature/SecretManagers/SecretManagerServicesTest.php b/tests/Feature/SecretManagers/SecretManagerServicesTest.php
new file mode 100644
index 0000000000..c4e4df36b7
--- /dev/null
+++ b/tests/Feature/SecretManagers/SecretManagerServicesTest.php
@@ -0,0 +1,205 @@
+ Http::response([
+ 'DATABASE_URL' => 'postgres://user:pass@host/db',
+ 'API_KEY' => 'secret-value',
+ ]),
+ ]);
+
+ $secrets = (new DopplerService('dp.st.test'))->fetchSecrets();
+
+ expect($secrets)->toBe([
+ 'DATABASE_URL' => 'postgres://user:pass@host/db',
+ 'API_KEY' => 'secret-value',
+ ]);
+
+ Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer dp.st.test')
+ && str_contains($request->url(), 'format=json')
+ && ! str_contains($request->url(), 'project='));
+ });
+
+ test('sends project and config for service account tokens', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response(['KEY' => 'value']),
+ ]);
+
+ (new DopplerService('dp.sa.test'))->fetchSecrets('my-project', 'prd');
+
+ Http::assertSent(fn ($request) => str_contains($request->url(), 'project=my-project')
+ && str_contains($request->url(), 'config=prd'));
+ });
+
+ test('throws a readable error when the download fails', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
+ 'messages' => ['Invalid Auth token'],
+ ], 401),
+ ]);
+
+ expect(fn () => (new DopplerService('bad-token'))->fetchSecrets())
+ ->toThrow(RuntimeException::class, 'Doppler API error: Invalid Auth token');
+ });
+
+ test('validates the token against the me endpoint', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/me' => Http::response(['type' => 'service_token']),
+ ]);
+
+ expect((new DopplerService('dp.st.test'))->validate())->toBeTrue();
+ });
+
+ test('validation fails for a rejected token', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/me' => Http::response([], 401),
+ ]);
+
+ expect((new DopplerService('bad'))->validate())->toBeFalse();
+ });
+});
+
+describe('InfisicalService', function () {
+ test('rejects an unapproved endpoint before sending credentials', function () {
+ Http::fake();
+
+ expect(fn () => new InfisicalService('http://127.0.0.1:8080', 'client-id', 'client-secret'))
+ ->toThrow(ValidationException::class);
+
+ Http::assertNothingSent();
+ });
+
+ test('logs in with universal auth and fetches secrets from the v4 endpoint', function () {
+ Http::fake([
+ 'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
+ 'accessToken' => 'short-lived-token',
+ ]),
+ 'https://example.com/infisical/api/v4/secrets*' => Http::response([
+ 'secrets' => [
+ ['secretKey' => 'DB_PASSWORD', 'secretValue' => 's3cret'],
+ ['secretKey' => 'API_KEY', 'secretValue' => 'abc'],
+ ],
+ ]),
+ ]);
+
+ $service = new InfisicalService('https://example.com/infisical/', 'client-id', 'client-secret');
+ $secrets = $service->fetchSecrets('project-1', 'prod', '/');
+
+ expect($secrets)->toBe([
+ 'DB_PASSWORD' => 's3cret',
+ 'API_KEY' => 'abc',
+ ]);
+
+ Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v4/secrets')
+ && $request->hasHeader('Authorization', 'Bearer short-lived-token')
+ && str_contains($request->url(), 'projectId=project-1'));
+ });
+
+ test('falls back to the v3 raw endpoint on older self-hosted instances', function () {
+ Http::fake([
+ 'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
+ 'accessToken' => 'short-lived-token',
+ ]),
+ 'https://example.com/infisical/api/v4/secrets*' => Http::response([], 404),
+ 'https://example.com/infisical/api/v3/secrets/raw*' => Http::response([
+ 'secrets' => [
+ ['secretKey' => 'LEGACY_KEY', 'secretValue' => 'legacy-value'],
+ ],
+ ]),
+ ]);
+
+ $service = new InfisicalService('https://example.com/infisical', 'client-id', 'client-secret');
+
+ expect($service->fetchSecrets('project-1', 'prod'))->toBe(['LEGACY_KEY' => 'legacy-value']);
+
+ Http::assertSent(fn ($request) => str_contains($request->url(), 'workspaceId=project-1'));
+ });
+
+ test('throws when the login fails', function () {
+ Http::fake([
+ 'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
+ 'message' => 'Invalid credentials',
+ ], 401),
+ ]);
+
+ $service = new InfisicalService('https://example.com/infisical', 'client-id', 'wrong');
+
+ expect($service->validate())->toBeFalse()
+ ->and(fn () => $service->fetchSecrets('project-1', 'prod'))
+ ->toThrow(RuntimeException::class, 'Infisical login failed: Invalid credentials');
+ });
+});
+
+describe('VaultService', function () {
+ test('rejects an unapproved endpoint before sending the token', function () {
+ Http::fake();
+
+ expect(fn () => new VaultService('http://127.0.0.1:8200', 'hvs.token'))
+ ->toThrow(ValidationException::class);
+
+ Http::assertNothingSent();
+ });
+
+ test('reads a kv v2 secret and stringifies non-string values', function () {
+ Http::fake([
+ 'https://example.com:8200/vault/v1/secret/data/my-app/production' => Http::response([
+ 'data' => [
+ 'data' => [
+ 'DB_PASSWORD' => 's3cret',
+ 'REPLICAS' => 3,
+ ],
+ ],
+ ]),
+ ]);
+
+ $secrets = (new VaultService('https://example.com:8200/vault/', 'hvs.token'))
+ ->fetchSecrets('secret', '/my-app/production/');
+
+ expect($secrets)->toBe([
+ 'DB_PASSWORD' => 's3cret',
+ 'REPLICAS' => '3',
+ ]);
+
+ Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Token', 'hvs.token')
+ && ! $request->hasHeader('X-Vault-Namespace'));
+ });
+
+ test('sends the namespace header when configured', function () {
+ Http::fake([
+ 'https://example.com:8200/vault/v1/secret/data/my-app' => Http::response([
+ 'data' => ['data' => ['KEY' => 'value']],
+ ]),
+ ]);
+
+ (new VaultService('https://example.com:8200/vault', 'hvs.token', 'admin/team-a'))
+ ->fetchSecrets('secret', 'my-app');
+
+ Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'admin/team-a'));
+ });
+
+ test('throws a readable error when the read fails', function () {
+ Http::fake([
+ 'https://example.com:8200/vault/v1/secret/data/missing' => Http::response([
+ 'errors' => ['permission denied'],
+ ], 403),
+ ]);
+
+ expect(fn () => (new VaultService('https://example.com:8200/vault', 'hvs.token'))->fetchSecrets('secret', 'missing'))
+ ->toThrow(RuntimeException::class, 'Vault API error: permission denied');
+ });
+
+ test('validates the token with lookup-self', function () {
+ Http::fake([
+ 'https://example.com:8200/vault/v1/auth/token/lookup-self' => Http::response(['data' => []]),
+ ]);
+
+ expect((new VaultService('https://example.com:8200/vault', 'hvs.token'))->validate())->toBeTrue();
+ });
+});
diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php
new file mode 100644
index 0000000000..ac43e9077c
--- /dev/null
+++ b/tests/Feature/Security/IntegrationTokenFormTest.php
@@ -0,0 +1,350 @@
+withoutDefer();
+ if (! InstanceSettings::query()->whereKey(0)->exists()) {
+ $settings = new InstanceSettings;
+ $settings->id = 0;
+ $settings->save();
+ }
+ Once::flush();
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+ $this->actingAs($this->user);
+});
+
+test('a cloudflare dns token is validated with read only requests before it is saved', function () {
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
+ 'success' => true,
+ 'result' => ['status' => 'active'],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'zone-id']],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([
+ 'success' => true,
+ 'result' => [],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]],
+ 'result_info' => ['total_pages' => 1],
+ ]),
+ ]);
+
+ Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true])
+ ->set('provider', 'cloudflare')
+ ->set('name', 'Production DNS')
+ ->set('token', 'cloudflare-token')
+ ->set('capabilities', ['dns'])
+ ->call('addToken')
+ ->assertHasNoErrors()
+ ->assertDispatched('close-modal');
+
+ $this->assertDatabaseHas('integration_tokens', [
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ ]);
+ $this->assertDatabaseHas('audit_events', [
+ 'team_id' => $this->team->id,
+ 'event' => 'ui.integration_token.created',
+ 'resource_name' => 'Production DNS',
+ ]);
+
+ Http::assertSentCount(4);
+ Http::assertSent(fn ($request) => $request->method() === 'GET'
+ && $request->url() === 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1');
+});
+
+test('automatic dns is enabled by default and can be disabled when saving a cloudflare token', function () {
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['success' => true, 'result' => ['status' => 'active']]),
+ 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response(['success' => true, 'result' => [['id' => 'zone-id']]]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response(['success' => true, 'result' => []]),
+ 'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]],
+ 'result_info' => ['total_pages' => 1],
+ ]),
+ ]);
+
+ Livewire::test(IntegrationTokenForm::class)
+ ->assertSet('automaticDns', true)
+ ->set('name', 'Manual DNS')
+ ->set('token', 'cloudflare-token')
+ ->set('automaticDns', false)
+ ->call('addToken')
+ ->assertHasNoErrors();
+
+ expect(IntegrationToken::query()->sole()->automaticDnsEnabled())->toBeFalse();
+});
+
+test('deleting an integration token is audited without storing its value', function () {
+ $token = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'doppler',
+ 'name' => 'Production secrets',
+ 'token' => 'dp.st.super-secret',
+ 'capabilities' => ['secrets'],
+ ]);
+
+ Livewire::test(IntegrationTokens::class)->call('deleteToken', $token->id);
+
+ $auditEvent = AuditEvent::query()->where('event', 'ui.integration_token.deleted')->sole();
+
+ expect($auditEvent->resource_uuid)->toBe($token->uuid)
+ ->and(json_encode($auditEvent->metadata))->not->toContain('dp.st.super-secret');
+});
+
+test('a cloudflare token is not saved when scope validation fails', function () {
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
+ 'success' => true,
+ 'result' => ['status' => 'active'],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
+ 'success' => false,
+ 'errors' => [['message' => 'Authentication error']],
+ ], 403),
+ ]);
+
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('name', 'Invalid DNS token')
+ ->set('token', 'cloudflare-token')
+ ->set('capabilities', ['dns'])
+ ->call('addToken')
+ ->assertDispatched('error');
+
+ $this->assertDatabaseCount('integration_tokens', 0);
+});
+
+test('at least one capability is required when adding a cloudflare token', function () {
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('name', 'Account token')
+ ->set('token', 'cloudflare-token')
+ ->set('capabilities', [])
+ ->call('addToken')
+ ->assertHasErrors(['capabilities' => 'required']);
+
+ $this->assertDatabaseCount('integration_tokens', 0);
+ Http::assertNothingSent();
+});
+
+test('provider validation uses the provider names declared by the model', function () {
+ $component = file_get_contents(app_path('Livewire/Security/IntegrationTokenForm.php'));
+
+ expect($component)
+ ->toContain("implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))")
+ ->not->toContain('in:cloudflare,doppler,infisical,vault');
+});
+
+test('integration tokens page lists saved provider and capabilities', function () {
+ IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'secret',
+ 'capabilities' => ['dns'],
+ ]);
+
+ Livewire::test(IntegrationTokens::class)
+ ->assertSee('Production DNS')
+ ->assertSee('Cloudflare')
+ ->assertSee('DNS');
+});
+
+test('cloudflare dns scope guidance and token creation link are shown', function () {
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('capabilities', ['dns'])
+ ->assertSee('Zone - DNS - Edit')
+ ->assertSee('Zone - Zone - Read')
+ ->assertSeeHtml('https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=%2A&zoneId=all&name=Coolify%20DNS%20Management');
+
+ expect(file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php')))
+ ->toContain('permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D');
+});
+
+test('capability selection uses the shared checkbox component', function () {
+ $view = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'));
+
+ expect($view)
+ ->toContain('toContain('class="mt-3 rounded-lg border')
+ ->not->toContain(' toContain('wire:target="addToken" isHighlighted')
+ ->not->toContain('class="button-highlighted"');
+});
+
+test('saved integration token rows render modal editors with a gear button', function () {
+ IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'original-token',
+ 'capabilities' => ['dns'],
+ ]);
+
+ Livewire::test(IntegrationTokens::class)
+ ->assertSee('Edit Integration Token')
+ ->assertSee('Production DNS')
+ ->assertSeeHtml(':aria-label="`Edit ${tokenName}`"');
+});
+
+test('an integration token can be rotated after validating its capabilities', function () {
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
+ 'success' => true,
+ 'result' => ['status' => 'active'],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'zone-id']],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([
+ 'success' => true,
+ 'result' => [],
+ ]),
+ 'https://api.cloudflare.com/client/v4/zones?page=1&per_page=50' => Http::response([
+ 'success' => true,
+ 'result' => [['id' => 'zone-id', 'name' => 'example.com', 'account' => ['id' => 'account-id', 'name' => 'Production']]],
+ 'result_info' => ['total_pages' => 1],
+ ]),
+ ]);
+
+ $savedToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'original-token',
+ 'capabilities' => ['dns'],
+ ]);
+
+ Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
+ ->set('name', 'Rotated DNS')
+ ->set('newToken', 'rotated-token')
+ ->call('save')
+ ->assertHasNoErrors()
+ ->assertDispatched('success');
+
+ $savedToken->refresh();
+
+ expect($savedToken->name)->toBe('Rotated DNS')
+ ->and($savedToken->token)->toBe('rotated-token');
+});
+
+test('leaving the token field blank keeps the existing integration token', function () {
+ Http::fake();
+
+ $savedToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'original-token',
+ 'capabilities' => ['dns'],
+ ]);
+
+ Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
+ ->set('name', 'Renamed DNS')
+ ->set('newToken', '')
+ ->call('save')
+ ->assertHasNoErrors();
+
+ $savedToken->refresh();
+
+ expect($savedToken->name)->toBe('Renamed DNS')
+ ->and($savedToken->token)->toBe('original-token');
+
+ Http::assertNothingSent();
+});
+
+test('cloudflare token editor lists the zones managed by that token', function () {
+ $savedToken = IntegrationToken::factory()->for($this->team)->create([
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'capabilities' => ['dns'],
+ ]);
+ DnsProviderZone::factory()->for($savedToken)->create([
+ 'name' => 'example.com',
+ 'account_name' => 'Production Account',
+ ]);
+ DnsProviderZone::factory()->create(['name' => 'other-team.example']);
+
+ Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
+ ->assertSee('Domains this token can manage')
+ ->assertSee('example.com')
+ ->assertSee('Production Account')
+ ->assertDontSee('other-team.example');
+});
+
+test('an invalid replacement does not rotate the integration token', function () {
+ Http::fake([
+ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
+ 'success' => false,
+ ], 403),
+ ]);
+
+ $savedToken = IntegrationToken::query()->create([
+ 'team_id' => $this->team->id,
+ 'provider' => 'cloudflare',
+ 'name' => 'Production DNS',
+ 'token' => 'original-token',
+ 'capabilities' => ['dns'],
+ ]);
+
+ Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
+ ->set('newToken', 'invalid-token')
+ ->call('save')
+ ->assertDispatched('error');
+
+ expect($savedToken->fresh()->token)->toBe('original-token');
+});
+
+test('editor updates its row without rerendering the teleported parent modal', function () {
+ $component = file_get_contents(app_path('Livewire/Security/IntegrationTokenEditor.php'));
+
+ expect($component)
+ ->toContain("'integration-token-updated'")
+ ->toContain("'integration-token-deleted'")
+ ->not->toContain('integrationTokenChanged');
+});
+
+test('new integration token form controls declare authorization matching server-side actions', function () {
+ $editor = file_get_contents(resource_path('views/livewire/security/integration-token-editor.blade.php'));
+ $form = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'));
+
+ expect($editor)
+ ->toMatch('/]*id="edit-automatic-dns")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$integrationToken")[^>]*>/')
+ ->toMatch('/]*wire:click="refreshZones")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$integrationToken")[^>]*>/');
+
+ expect($form)
+ ->toMatch('/]*id="automatic-dns")(?=[^>]*canGate="create")(?=[^>]*:canResource="\\\\App\\\\Models\\\\IntegrationToken::class")[^>]*>/');
+});
diff --git a/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php b/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php
new file mode 100644
index 0000000000..794195504a
--- /dev/null
+++ b/tests/Feature/Security/IntegrationTokenSecretProvidersTest.php
@@ -0,0 +1,191 @@
+whereKey(0)->exists()) {
+ $settings = new InstanceSettings;
+ $settings->id = 0;
+ $settings->save();
+ }
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+ $this->actingAs($this->user);
+});
+
+test('a doppler token is validated against the doppler api before it is saved', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/me' => Http::response(['type' => 'service_token']),
+ ]);
+
+ Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true])
+ ->set('provider', 'doppler')
+ ->set('name', 'Production secrets')
+ ->set('token', 'dp.st.token')
+ ->call('addToken')
+ ->assertHasNoErrors()
+ ->assertDispatched('close-modal');
+
+ $this->assertDatabaseHas('integration_tokens', [
+ 'team_id' => $this->team->id,
+ 'provider' => 'doppler',
+ 'name' => 'Production secrets',
+ ]);
+});
+
+test('selecting a secret manager provider switches the capability to secrets', function () {
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', 'doppler')
+ ->assertSet('capabilities', ['secrets'])
+ ->set('provider', 'cloudflare')
+ ->assertSet('capabilities', ['dns']);
+});
+
+test('an invalid doppler token is not saved', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/me' => Http::response([], 401),
+ ]);
+
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', 'doppler')
+ ->set('name', 'Bad token')
+ ->set('token', 'dp.st.rejected')
+ ->call('addToken')
+ ->assertHasNoErrors()
+ ->assertDispatched('error');
+
+ $this->assertDatabaseCount('integration_tokens', 0);
+});
+
+test('doppler only accepts service and service account tokens', function (string $token) {
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', 'doppler')
+ ->set('name', 'Unsupported token')
+ ->set('token', $token)
+ ->call('addToken')
+ ->assertHasErrors(['token']);
+
+ $this->assertDatabaseCount('integration_tokens', 0);
+})->with([
+ 'personal token' => 'dp.pt.token',
+ 'unknown token' => 'token',
+]);
+
+test('a doppler service account token is accepted', function () {
+ Http::fake([
+ 'https://api.doppler.com/v3/me' => Http::response(['type' => 'service_account']),
+ ]);
+
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', 'doppler')
+ ->set('name', 'Shared secrets')
+ ->set('token', 'dp.sa.token')
+ ->call('addToken')
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseHas('integration_tokens', [
+ 'provider' => 'doppler',
+ 'name' => 'Shared secrets',
+ ]);
+});
+
+test('an infisical token requires a base url and a client id', function () {
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', 'infisical')
+ ->set('name', 'Infisical')
+ ->set('token', 'client-secret')
+ ->set('metadata', [])
+ ->call('addToken')
+ ->assertHasErrors(['metadata.base_url', 'metadata.client_id']);
+
+ $this->assertDatabaseCount('integration_tokens', 0);
+});
+
+test('secret manager provider base urls only accept http and https', function (string $provider, array $metadata) {
+ Http::fake();
+
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', $provider)
+ ->set('name', 'Invalid base URL')
+ ->set('token', 'token')
+ ->set('metadata', $metadata)
+ ->call('addToken')
+ ->assertHasErrors(['metadata.base_url']);
+
+ Http::assertNothingSent();
+ $this->assertDatabaseCount('integration_tokens', 0);
+})->with([
+ 'infisical' => ['infisical', ['base_url' => 'ftp://infisical.example.com', 'client_id' => 'client-1']],
+ 'vault' => ['vault', ['base_url' => 'ftp://vault.example.com']],
+]);
+
+test('the infisical fields put the client id before the client secret', function () {
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', 'infisical')
+ ->assertSeeInOrder(['Token name', 'Client ID', 'Client secret', 'Base URL']);
+});
+
+test('an infisical token stores its metadata after a successful login', function () {
+ Http::fake([
+ 'https://example.com/api/v1/auth/universal-auth/login' => Http::response([
+ 'accessToken' => 'token',
+ ]),
+ ]);
+
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', 'infisical')
+ ->set('name', 'Infisical')
+ ->set('token', 'client-secret')
+ ->set('metadata', ['base_url' => 'https://example.com', 'client_id' => 'client-1'])
+ ->call('addToken')
+ ->assertHasNoErrors();
+
+ $token = IntegrationToken::query()->where('provider', 'infisical')->firstOrFail();
+
+ expect($token->metadata)->toBe(['base_url' => 'https://example.com', 'client_id' => 'client-1'])
+ ->and($token->capabilities)->toBe(['secrets']);
+});
+
+test('a vault token is validated with lookup-self before it is saved', function () {
+ Http::fake([
+ 'https://example.com:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]),
+ ]);
+
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', 'vault')
+ ->set('name', 'Vault')
+ ->set('token', 'hvs.token')
+ ->set('metadata', ['base_url' => 'https://example.com:8200'])
+ ->call('addToken')
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseHas('integration_tokens', [
+ 'provider' => 'vault',
+ 'name' => 'Vault',
+ ]);
+});
+
+test('the dns capability is rejected for secret manager providers', function () {
+ Livewire::test(IntegrationTokenForm::class)
+ ->set('provider', 'doppler')
+ ->set('name', 'Doppler')
+ ->set('token', 'dp.st.token')
+ ->set('capabilities', ['dns'])
+ ->call('addToken')
+ ->assertHasErrors(['capabilities.0']);
+
+ $this->assertDatabaseCount('integration_tokens', 0);
+});
diff --git a/tests/Feature/SecuritySettingsNavigationTest.php b/tests/Feature/SecuritySettingsNavigationTest.php
index e89bf8093a..9eab92cc28 100644
--- a/tests/Feature/SecuritySettingsNavigationTest.php
+++ b/tests/Feature/SecuritySettingsNavigationTest.php
@@ -8,6 +8,7 @@ it('uses shared sidebar navigation for keys and tokens pages', function () {
'security/private-key/index.blade.php',
'security/private-key/show.blade.php',
'security/cloud-tokens.blade.php',
+ 'security/integration-tokens.blade.php',
'security/cloud-provider-token/show.blade.php',
'security/cloud-init-scripts.blade.php',
'security/cloud-init-script/show.blade.php',
@@ -22,6 +23,7 @@ it('uses shared sidebar navigation for keys and tokens pages', function () {
->toContain('application-settings-navigation')
->toContain("'label' => 'Private Keys'")
->toContain("'label' => 'Cloud Tokens'")
+ ->toContain("'label' => 'Integration Tokens'")
->toContain("'label' => 'Cloud-Init Scripts'")
->toContain("'label' => 'API Tokens'");
diff --git a/tests/Feature/SentinelUnsavedBarFlashTest.php b/tests/Feature/SentinelUnsavedBarFlashTest.php
index 0c5d196c03..937d7cad5c 100644
--- a/tests/Feature/SentinelUnsavedBarFlashTest.php
+++ b/tests/Feature/SentinelUnsavedBarFlashTest.php
@@ -12,10 +12,22 @@ test('sentinel unsaved bar scopes dirty tracking to savable form fields', functi
expect($contents)
->toContain('x-unsaved-bar')
- ->toContain('targets="sentinelCustomUrl,sentinelToken,sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds"')
+ ->toContain('targets="sentinelCustomUrl,sentinelToken"')
+ ->not->toContain('trafficTopn')
+ ->not->toContain('sentinelMetricsRefreshRateSeconds')
+ ->not->toContain('sentinelMetricsHistoryDays')
+ ->not->toContain('sentinelPushIntervalSeconds')
->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/');
});
+test('metrics unsaved bar scopes dirty tracking to metrics collection fields', function () {
+ $contents = file_get_contents(resource_path('views/livewire/server/charts.blade.php'));
+
+ expect($contents)
+ ->toContain('x-unsaved-bar action="saveMetricsSettings"')
+ ->toContain('targets="sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds"');
+});
+
test('unsaved bar component accepts optional wire:target list', function () {
$path = resource_path('views/components/unsaved-bar.blade.php');
$contents = file_get_contents($path);
@@ -141,6 +153,17 @@ test('sentinel custom docker image x-init only sets wire when a value exists', f
->not->toContain("x-init=\"\$wire.set('sentinelCustomDockerImage', customImage)\"");
});
+test('sentinel custom docker image does not rerender while typing and has an explicit apply action', function () {
+ $contents = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
+
+ expect($contents)
+ ->toContain('async applyCustomImage()')
+ ->toContain("await \$wire.set('sentinelCustomDockerImage', this.customImage || null)")
+ ->toContain('await $wire.restartSentinel()')
+ ->toContain('Apply and restart')
+ ->not->toContain('@input.debounce.500ms="saveCustomImage()"');
+});
+
/**
* Instant-save listboxes (e.g. MCP server) entangle + call instantSave. Until the
* round-trip finishes, the component is dirty β so an unscoped unsaved bar flashes.
diff --git a/tests/Feature/Server/ServerMetricsSettingsTest.php b/tests/Feature/Server/ServerMetricsSettingsTest.php
new file mode 100644
index 0000000000..16ac1d919c
--- /dev/null
+++ b/tests/Feature/Server/ServerMetricsSettingsTest.php
@@ -0,0 +1,93 @@
+user = User::factory()->create();
+ $this->team = $this->user->teams()->first();
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+it('shows metrics collection settings inside the main metrics section instead of a separate section', function () {
+ $metricsView = file_get_contents(resource_path('views/livewire/server/charts.blade.php'));
+ $sentinelView = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
+
+ expect($metricsView)
+ ->not->toContain('id="server-metrics-collection-section"')
+ ->toContain('id="sentinelMetricsRefreshRateSeconds"')
+ ->toContain('id="sentinelMetricsHistoryDays"')
+ ->toContain('id="sentinelPushIntervalSeconds"')
+ ->and($sentinelView)
+ ->not->toContain('id="server-sentinel-metrics-section"')
+ ->not->toContain('id="sentinelMetricsRefreshRateSeconds"')
+ ->not->toContain('id="sentinelMetricsHistoryDays"')
+ ->not->toContain('id="sentinelPushIntervalSeconds"');
+});
+
+it('saves metrics collection settings from the server metrics page', function () {
+ Queue::fake();
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_sentinel_enabled = true;
+ $server->settings->is_metrics_enabled = true;
+ $server->settings->save();
+
+ Livewire::test(Charts::class, ['server_uuid' => $server->uuid])
+ ->set('sentinelMetricsRefreshRateSeconds', 15)
+ ->set('sentinelMetricsHistoryDays', 14)
+ ->set('sentinelPushIntervalSeconds', 90)
+ ->call('saveMetricsSettings')
+ ->assertHasNoErrors();
+
+ $settings = $server->settings->fresh();
+
+ expect($settings->sentinel_metrics_refresh_rate_seconds)->toBe(15)
+ ->and($settings->sentinel_metrics_history_days)->toBe(14)
+ ->and($settings->sentinel_push_interval_seconds)->toBe(90);
+});
+
+it('validates metrics collection settings on the server metrics page', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ Livewire::test(Charts::class, ['server_uuid' => $server->uuid])
+ ->set('sentinelMetricsRefreshRateSeconds', 0)
+ ->set('sentinelMetricsHistoryDays', 0)
+ ->set('sentinelPushIntervalSeconds', 9)
+ ->call('saveMetricsSettings')
+ ->assertHasErrors([
+ 'sentinelMetricsRefreshRateSeconds',
+ 'sentinelMetricsHistoryDays',
+ 'sentinelPushIntervalSeconds',
+ ]);
+});
+
+it('uses the local datetime axis so server charts show day separators', function () {
+ $metricsView = file_get_contents(resource_path('views/livewire/server/charts.blade.php'));
+
+ expect($metricsView)
+ ->not->toContain('datetimeUTC: true')
+ ->and(substr_count($metricsView, 'datetimeUTC: false'))
+ ->toBe(3);
+});
+
+it('updates CPU and memory charts together when the time range changes', function () {
+ $component = file_get_contents(app_path('Livewire/Server/Charts.php'));
+ $metricsView = file_get_contents(resource_path('views/livewire/server/charts.blade.php'));
+
+ expect($component)
+ ->toContain('"refreshChartData-{$this->chartId}-metrics"')
+ ->toContain("'cpuSeries' => \$cpuMetrics")
+ ->toContain("'memorySeries' => \$memoryMetrics")
+ ->and($metricsView)
+ ->toContain("Livewire.on('refreshChartData-{!! \$chartId !!}-metrics'")
+ ->toContain('data.cpuSeries')
+ ->toContain('data.memorySeries');
+});
diff --git a/tests/Feature/Server/ServerSettingSentinelRestartTest.php b/tests/Feature/Server/ServerSettingSentinelRestartTest.php
index 7a1c333ca7..8e2b2b32b0 100644
--- a/tests/Feature/Server/ServerSettingSentinelRestartTest.php
+++ b/tests/Feature/Server/ServerSettingSentinelRestartTest.php
@@ -1,13 +1,23 @@
getAction() instanceof StartSentinel;
+}
+
beforeEach(function () {
+ Queue::fake();
+
// Create user (which automatically creates a team)
$user = User::factory()->create();
$this->team = $user->teams()->first();
@@ -108,7 +118,14 @@ it('does not detect changes when unrelated field is changed', function () {
$settings->wasChanged('sentinel_custom_url') ||
$settings->wasChanged('sentinel_metrics_refresh_rate_seconds') ||
$settings->wasChanged('sentinel_metrics_history_days') ||
- $settings->wasChanged('sentinel_push_interval_seconds')
+ $settings->wasChanged('sentinel_push_interval_seconds') ||
+ $settings->wasChanged('traffic_topn') ||
+ $settings->wasChanged('traffic_sample_threshold') ||
+ $settings->wasChanged('traffic_retention_1h_days') ||
+ $settings->wasChanged('traffic_retention_1d_days') ||
+ $settings->wasChanged('is_geoip_enabled') ||
+ $settings->wasChanged('geoip_refresh_days') ||
+ $settings->wasChanged('geoip_maxmind_license_key')
) {
$changeDetected = true;
}
@@ -121,6 +138,44 @@ it('does not detect changes when unrelated field is changed', function () {
expect($changeDetected)->toBeFalse();
});
+it('detects traffic analytics setting changes with wasChanged', function () {
+ $changeDetected = false;
+
+ ServerSetting::updated(function ($settings) use (&$changeDetected) {
+ if ($settings->wasChanged('traffic_topn')) {
+ $changeDetected = true;
+ }
+ });
+
+ $settings = $this->server->settings;
+ $settings->traffic_topn = 200;
+ $settings->save();
+
+ expect($changeDetected)->toBeTrue();
+});
+
+it('does not restart sentinel when a traffic knob changes while sentinel is disabled', function () {
+ $settings = $this->server->settings;
+ $settings->is_sentinel_enabled = false;
+ $settings->save();
+
+ $settings->traffic_topn = 999;
+ $settings->save();
+
+ Queue::assertNotPushed(JobDecorator::class, fn ($job) => isStartSentinelJob($job));
+});
+
+it('restarts sentinel when a traffic knob changes while sentinel is enabled', function () {
+ $settings = $this->server->settings;
+ $settings->is_sentinel_enabled = true;
+ $settings->save();
+
+ $settings->traffic_topn = 888;
+ $settings->save();
+
+ Queue::assertPushed(JobDecorator::class, fn ($job) => isStartSentinelJob($job));
+});
+
it('does not detect changes when sentinel field is set to same value', function () {
$changeDetected = false;
diff --git a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
index c19e13b796..0afd70dffc 100644
--- a/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
+++ b/tests/Feature/ServiceDatabaseVerticalNavigationTest.php
@@ -70,7 +70,7 @@ it('groups application navigation by user workflow', function () {
expect($application)
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck']")
- ->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics']")
+ ->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics', 'Analytics']")
->toContain("'Deploy' => ['Git Source', 'Servers', 'Preview Deployments']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']")
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone']");
diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php
index 19a87c99dc..dc07e204de 100644
--- a/tests/Feature/ServiceDomainsTest.php
+++ b/tests/Feature/ServiceDomainsTest.php
@@ -2,8 +2,11 @@
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Project\Service\Domains;
+use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\InstanceSettings;
+use App\Models\IntegrationToken;
+use App\Models\ManagedDnsRecord;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
@@ -13,6 +16,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
@@ -176,6 +180,75 @@ it('removes consecutive service domains by stable row identity after indexes cha
expect($this->apiApp->fresh()->fqdn)->toBe('https://third.example.com');
});
+it('deletes the managed dns record when removing a service domain by key with deleteManagedDns', function () {
+ $token = IntegrationToken::factory()->for($this->team)->create([
+ 'provider' => 'cloudflare',
+ 'token' => 'secret',
+ ]);
+ $zone = DnsProviderZone::factory()->for($token)->create([
+ 'provider_zone_id' => 'zone-1',
+ 'name' => 'example.com',
+ ]);
+ $record = ManagedDnsRecord::factory()->create([
+ 'team_id' => $this->team->id,
+ 'integration_token_id' => $token->id,
+ 'dns_provider_zone_id' => $zone->id,
+ 'resource_type' => $this->apiApp->getMorphClass(),
+ 'resource_id' => $this->apiApp->getKey(),
+ 'provider_record_id' => 'record-1',
+ 'type' => 'A',
+ 'name' => 'api.example.com',
+ 'content' => '203.0.113.10',
+ ]);
+
+ Http::fake(['https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::sequence()
+ ->push(['success' => true, 'result' => [
+ 'id' => 'record-1',
+ 'type' => 'A',
+ 'name' => 'api.example.com',
+ 'content' => '203.0.113.10',
+ ]])
+ ->push(['success' => true, 'result' => ['id' => 'record-1']])]);
+
+ $domainKey = hash('sha256', 'https://api.example.com|'.$this->apiApp->id);
+
+ Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
+ ->call('removeDomainByKey', $domainKey, '', ['deleteManagedDns'])
+ ->assertDispatched('success');
+
+ expect($this->apiApp->fresh()->fqdn)->toBeNull()
+ ->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
+});
+
+it('leaves the managed dns record when removing a service domain by key without deleteManagedDns', function () {
+ $token = IntegrationToken::factory()->for($this->team)->create([
+ 'provider' => 'cloudflare',
+ 'token' => 'secret',
+ ]);
+ $zone = DnsProviderZone::factory()->for($token)->create([
+ 'provider_zone_id' => 'zone-1',
+ 'name' => 'example.com',
+ ]);
+ $record = ManagedDnsRecord::factory()->create([
+ 'team_id' => $this->team->id,
+ 'integration_token_id' => $token->id,
+ 'dns_provider_zone_id' => $zone->id,
+ 'provider_record_id' => 'record-1',
+ 'type' => 'A',
+ 'name' => 'api.example.com',
+ 'content' => '203.0.113.10',
+ ]);
+
+ $domainKey = hash('sha256', 'https://api.example.com|'.$this->apiApp->id);
+
+ Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
+ ->call('removeDomainByKey', $domainKey, '')
+ ->assertDispatched('success');
+
+ expect($this->apiApp->fresh()->fqdn)->toBeNull()
+ ->and(ManagedDnsRecord::query()->find($record->id))->not->toBeNull();
+});
+
it('shows and persists the HTTP redirect control for HTTPS service applications', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('Redirect HTTP to HTTPS')
diff --git a/tests/Feature/SettingsEmailProviderExclusivityTest.php b/tests/Feature/SettingsEmailProviderExclusivityTest.php
new file mode 100644
index 0000000000..7e6b6ff232
--- /dev/null
+++ b/tests/Feature/SettingsEmailProviderExclusivityTest.php
@@ -0,0 +1,64 @@
+settings = new InstanceSettings;
+ $this->settings->id = 0;
+ $this->settings->save();
+ $this->rootTeam = Team::factory()->create(['id' => 0]);
+ $this->user = User::factory()->create();
+ $this->user->teams()->attach($this->rootTeam, ['role' => 'owner']);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->rootTeam]);
+});
+
+test('enabling SMTP disables Resend in storage', function () {
+ $this->settings->update([
+ 'resend_enabled' => true,
+ 'resend_api_key' => 're_test_key',
+ 'smtp_from_address' => 'from@example.com',
+ 'smtp_from_name' => 'Coolify',
+ ]);
+
+ Livewire::test(SettingsEmail::class)
+ ->set('smtpHost', 'smtp.example.com')
+ ->set('smtpPort', '587')
+ ->set('smtpEncryption', 'starttls')
+ ->set('smtpFromAddress', 'from@example.com')
+ ->set('smtpFromName', 'Coolify')
+ ->call('toggleSmtp');
+
+ $this->settings->refresh();
+ expect($this->settings->smtp_enabled)->toBeTrue();
+ expect($this->settings->resend_enabled)->toBeFalse();
+});
+
+test('enabling Resend disables SMTP in storage', function () {
+ $this->settings->update([
+ 'smtp_enabled' => true,
+ 'smtp_host' => 'smtp.example.com',
+ 'smtp_port' => '587',
+ 'smtp_encryption' => 'starttls',
+ 'smtp_from_address' => 'from@example.com',
+ 'smtp_from_name' => 'Coolify',
+ ]);
+
+ Livewire::test(SettingsEmail::class)
+ ->set('resendApiKey', 're_test_key')
+ ->set('smtpFromAddress', 'from@example.com')
+ ->set('smtpFromName', 'Coolify')
+ ->call('toggleResend');
+
+ $this->settings->refresh();
+ expect($this->settings->resend_enabled)->toBeTrue();
+ expect($this->settings->smtp_enabled)->toBeFalse();
+});
diff --git a/tests/Feature/SettingsNavigationTest.php b/tests/Feature/SettingsNavigationTest.php
new file mode 100644
index 0000000000..96b01d8d98
--- /dev/null
+++ b/tests/Feature/SettingsNavigationTest.php
@@ -0,0 +1,52 @@
+blade(' ')
+ ->assertSeeText('Configuration')
+ ->assertSeeText('OAuth')
+ ->assertSeeText('Scheduled Jobs')
+ ->assertDontSeeText('Instance Backup')
+ ->assertDontSeeText('Transactional Email');
+});
+
+it('shows backup and transactional email in the settings configuration sidebar', function () {
+ $view = $this->blade(' ')
+ ->assertSeeTextInOrder([
+ 'General',
+ 'Advanced',
+ 'Instance Backup',
+ 'Transactional Email',
+ 'Updates',
+ ]);
+
+ expect((string) $view)
+ ->toContain(route('settings.backup'))
+ ->toContain(route('settings.email'))
+ ->and(substr_count((string) $view, 'menu-item-active'))->toBe(1);
+});
+
+it('renders backup and transactional email pages with the settings configuration sidebar', function () {
+ expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
+ ->toContain(' ')
+ ->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php')))
+ ->toContain(' ');
+});
+
+it('uses the same title and description spacing on backup and transactional email settings pages', function () {
+ expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
+ ->not->toContain('class="flex items-center gap-2 pb-2"')
+ ->toContain('Instance backup configuration for Coolify instance.
')
+ ->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php')))
+ ->not->toContain('class="flex flex-col gap-2 pb-4"')
+ ->toContain('Instance wide email settings for password resets, invitations, etc.
');
+});
+
+it('uses instance backup as the backup settings label', function () {
+ expect(file_get_contents(resource_path('views/components/settings/sidebar.blade.php')))
+ ->toContain('')
+ ->not->toContain('')
+ ->and(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
+ ->toContain('Instance Backup ')
+ ->toContain('Instance backup configuration for Coolify instance.')
+ ->not->toContain('Backup ');
+});
diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php
new file mode 100644
index 0000000000..95ea47e948
--- /dev/null
+++ b/tests/Feature/SettingsOauthTest.php
@@ -0,0 +1,277 @@
+ 0, 'name' => 'Root Team', 'personal_team' => true]);
+ $user = User::factory()->create(['id' => 0, 'email' => 'root@example.com', 'email_verified_at' => now()]);
+ if (! $user->teams()->whereKey($team->id)->exists()) {
+ $user->teams()->attach($team, ['role' => 'owner']);
+ }
+ session(['currentTeam' => $team]);
+ test()->actingAs($user);
+
+ return $user;
+}
+
+beforeEach(function () {
+ $this->withoutVite();
+ config()->set('app.maintenance.driver', 'file');
+
+ InstanceSettings::forceCreate(['id' => 0, 'is_registration_enabled' => true]);
+ Once::flush();
+ OauthSetting::create(['provider' => 'oidc']);
+ OauthSetting::create(['provider' => 'authentik']);
+ OauthSetting::create(['provider' => 'bitbucket']);
+});
+
+it('uses the standard settings design and keeps every oauth provider on one page', function () {
+ actingAsInstanceAdmin();
+
+ $this->withoutMiddleware(DecideWhatToDoWithUser::class)
+ ->get(route('settings.oauth'))
+ ->assertSuccessful()
+ ->assertSee('Authentication')
+ ->assertSee('Registration')
+ ->assertSee('Authentik')
+ ->assertSee('Bitbucket')
+ ->assertSee('OpenID Connect')
+ ->assertSee('Disable password registration when OAuth is enabled')
+ ->assertSee('Client secret')
+ ->assertSee('application-settings-form', false)
+ ->assertDontSee(route('settings.oauth.provider', 'authentik'), false);
+});
+
+it('lists openid connect before the other oauth providers', function () {
+ actingAsInstanceAdmin();
+
+ $providers = array_keys(Livewire::test(SettingsOauth::class)->get('oauth_settings_map'));
+
+ expect($providers[0])->toBe('oidc');
+});
+
+it('has an icon for openid connect', function () {
+ expect(public_path('svgs/oidc.svg'))->toBeFile();
+});
+
+it('auto saves registration policy without a general save button', function () {
+ actingAsInstanceAdmin();
+
+ $this->withoutMiddleware(DecideWhatToDoWithUser::class)
+ ->get(route('settings.oauth'))
+ ->assertSuccessful()
+ ->assertSee("wire:click='saveRegistrationPolicy'", false)
+ ->assertDontSee('Save', false);
+
+ Livewire::test(SettingsOauth::class)
+ ->set('disable_registration_when_oauth_enabled', true)
+ ->call('saveRegistrationPolicy')
+ ->assertHasNoErrors()
+ ->assertDispatched('success');
+
+ expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
+});
+
+it('shows oidc fields with a naked okta issuer url example', function () {
+ actingAsInstanceAdmin();
+
+ $this->withoutMiddleware(DecideWhatToDoWithUser::class)
+ ->get(route('settings.oauth'))
+ ->assertSuccessful()
+ ->assertSee('OpenID Connect')
+ ->assertSee('https://example.okta.com', false)
+ ->assertDontSee('/oauth2/default', false);
+});
+
+it('groups oidc fields in the expected desktop order', function () {
+ $view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php'));
+ $fields = [
+ 'redirect_uri',
+ 'base_url',
+ 'client_id',
+ 'client_secret',
+ 'scopes',
+ 'clock_skew_seconds',
+ 'custom_label',
+ ];
+ $positions = array_map(
+ fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""),
+ $fields,
+ );
+
+ expect($positions)->not->toContain(false)
+ ->and($positions)->toBe(collect($positions)->sort()->values()->all())
+ ->and($view)->toContain('');
+});
+
+it('shows provider enable controls as settings section actions', function () {
+ actingAsInstanceAdmin();
+
+ $this->withoutMiddleware(DecideWhatToDoWithUser::class)
+ ->get(route('settings.oauth'))
+ ->assertSuccessful()
+ ->assertSee('Enable')
+ ->assertDontSee('label="Enabled"', false)
+ ->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false);
+});
+
+it('stacks oidc option checkboxes vertically', function () {
+ actingAsInstanceAdmin();
+
+ $this->withoutMiddleware(DecideWhatToDoWithUser::class)
+ ->get(route('settings.oauth'))
+ ->assertSuccessful()
+ ->assertSee('Allow OIDC user creation')
+ ->assertSee('Require verified email')
+ ->assertSee('Use PKCE')
+ ->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false);
+});
+
+it('does not show unknown oauth providers', function () {
+ actingAsInstanceAdmin();
+
+ $this->withoutMiddleware(DecideWhatToDoWithUser::class)
+ ->get('/settings/oauth/unknown')
+ ->assertNotFound();
+});
+
+it('defaults oidc user creation and verified email requirement to enabled', function () {
+ $setting = OauthSetting::where('provider', 'oidc')->first();
+
+ expect($setting->allow_registration)->toBeTrue()
+ ->and($setting->require_email_verified)->toBeTrue()
+ ->and($setting->auto_join_root_team)->toBeFalse();
+});
+
+it('persists oidc oauth settings from livewire', function () {
+ actingAsInstanceAdmin();
+
+ Livewire::test(SettingsOauth::class)
+ ->set('oauth_settings_map.oidc.enabled', true)
+ ->set('oauth_settings_map.oidc.client_id', 'client-id')
+ ->set('oauth_settings_map.oidc.client_secret', 'secret')
+ ->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback')
+ ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com')
+ ->set('oauth_settings_map.oidc.scopes', 'openid email profile groups')
+ ->set('oauth_settings_map.oidc.custom_label', 'Login with Okta')
+ ->set('oauth_settings_map.oidc.allow_registration', true)
+ ->set('oauth_settings_map.oidc.auto_join_root_team', true)
+ ->set('oauth_settings_map.oidc.require_email_verified', true)
+ ->set('disable_registration_when_oauth_enabled', true)
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $setting = OauthSetting::where('provider', 'oidc')->first();
+ expect($setting->enabled)->toBeTrue()
+ ->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback')
+ ->and($setting->base_url)->toBe('https://idp.example.com')
+ ->and($setting->custom_label)->toBe('Login with Okta')
+ ->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups'])
+ ->and($setting->allow_registration)->toBeTrue()
+ ->and($setting->auto_join_root_team)->toBeTrue();
+
+ expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
+});
+
+it('saves only the selected provider from provider pages', function () {
+ actingAsInstanceAdmin();
+
+ Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
+ ->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url')
+ ->set('oauth_settings_map.authentik.enabled', true)
+ ->set('oauth_settings_map.authentik.client_id', 'authentik-client')
+ ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret')
+ ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com')
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $setting = OauthSetting::where('provider', 'authentik')->first();
+ expect($setting->enabled)->toBeTrue()
+ ->and($setting->client_id)->toBe('authentik-client')
+ ->and($setting->base_url)->toBe('https://authentik.example.com');
+});
+
+it('validates oidc url fields before saving', function (string $field, string $value) {
+ actingAsInstanceAdmin();
+
+ Livewire::test(SettingsOauth::class)
+ ->set('oauth_settings_map.oidc.client_id', 'client-id')
+ ->set('oauth_settings_map.oidc.client_secret', 'secret')
+ ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com')
+ ->set("oauth_settings_map.oidc.$field", $value)
+ ->call('submit')
+ ->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']);
+
+ $setting = OauthSetting::where('provider', 'oidc')->first();
+ expect($setting->{$field})->toBeNull();
+})->with([
+ 'invalid redirect uri' => ['redirect_uri', 'not-a-url'],
+ 'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'],
+ 'invalid issuer url' => ['base_url', 'not-a-url'],
+ 'non-http issuer url' => ['base_url', 'ftp://idp.example.com'],
+]);
+
+it('does not enable oidc without required fields', function () {
+ actingAsInstanceAdmin();
+
+ Livewire::test(SettingsOauth::class)
+ ->set('oauth_settings_map.oidc.enabled', true)
+ ->call('instantSave', 'oidc')
+ ->assertDispatched('error');
+
+ expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse();
+});
+
+it('keeps provider disabled in the ui when enable validation fails', function () {
+ actingAsInstanceAdmin();
+
+ Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
+ ->call('toggleProvider', 'authentik')
+ ->assertDispatched('error')
+ ->assertSet('oauth_settings_map.authentik.enabled', false);
+
+ expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse();
+});
+
+it('disables an enabled provider gracefully when required fields become incomplete', function () {
+ actingAsInstanceAdmin();
+
+ OauthSetting::where('provider', 'authentik')->first()->forceFill([
+ 'enabled' => true,
+ 'client_id' => 'authentik-client',
+ 'client_secret' => 'authentik-secret',
+ 'base_url' => 'https://authentik.example.com',
+ ])->save();
+
+ Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
+ ->set('oauth_settings_map.authentik.client_secret', '')
+ ->call('submit')
+ ->assertDispatched('error')
+ ->assertSet('oauth_settings_map.authentik.enabled', false);
+
+ expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse();
+});
+
+it('toggles provider enabled state from the action button', function () {
+ actingAsInstanceAdmin();
+
+ Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
+ ->set('oauth_settings_map.authentik.client_id', 'authentik-client')
+ ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret')
+ ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com')
+ ->call('toggleProvider', 'authentik')
+ ->assertHasNoErrors();
+
+ expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue();
+});
diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php
index 45e150dfab..272156fbd2 100644
--- a/tests/Feature/SshMultiplexingLockTest.php
+++ b/tests/Feature/SshMultiplexingLockTest.php
@@ -153,7 +153,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
- ->toContain("'bash -se' << \\")
+ ->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\")
->not->toContain('<< $delimiter');
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
diff --git a/tests/Feature/StartDatabaseImportConcurrencyTest.php b/tests/Feature/StartDatabaseImportConcurrencyTest.php
new file mode 100644
index 0000000000..d7d27a6c8c
--- /dev/null
+++ b/tests/Feature/StartDatabaseImportConcurrencyTest.php
@@ -0,0 +1,125 @@
+set('cache.default', 'array');
+ InstanceSettings::forceCreate(['id' => 0]);
+ $this->team = Team::factory()->create();
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::firstOrCreate(
+ ['server_id' => $this->server->id, 'network' => 'coolify'],
+ ['uuid' => (string) Str::uuid(), 'name' => 'docker']
+ );
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+ $this->database = StandalonePostgresql::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'db',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'db',
+ 'image' => 'postgres:17',
+ 'status' => 'running',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+});
+
+function startImport(object $database, int $teamId, string $path = 'backup.sql'): Activity
+{
+ return app(StartDatabaseImport::class)->handle(
+ $database,
+ new DatabaseImportSource('server', path: $path),
+ $teamId,
+ );
+}
+
+test('a held import lock rejects a second start before restore commands run', function () {
+ $lock = Cache::lock(StartDatabaseImport::lockKey($this->database->uuid), StartDatabaseImport::LOCK_SECONDS);
+ expect($lock->get())->toBeTrue();
+
+ try {
+ expect(fn () => startImport($this->database, $this->team->id))
+ ->toThrow(DatabaseImportException::class, 'A database import is already running.');
+ } finally {
+ $lock->release();
+ }
+});
+
+test('an already queued import is still rejected after the lock is acquired', function () {
+ Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'queued',
+ 'properties' => [
+ 'team_id' => $this->team->id,
+ 'type_uuid' => $this->database->uuid,
+ 'operation' => 'database_import',
+ 'status' => ProcessStatus::QUEUED->value,
+ ],
+ ]);
+
+ expect(fn () => startImport($this->database, $this->team->id))
+ ->toThrow(DatabaseImportException::class, 'A database import is already running.');
+});
+
+test('an in-progress import is rejected the same way as a queued import', function () {
+ Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'in progress',
+ 'properties' => [
+ 'team_id' => $this->team->id,
+ 'type_uuid' => $this->database->uuid,
+ 'operation' => 'database_import',
+ 'status' => ProcessStatus::IN_PROGRESS->value,
+ ],
+ ]);
+
+ expect(fn () => startImport($this->database, $this->team->id))
+ ->toThrow(DatabaseImportException::class, 'A database import is already running.');
+});
+
+test('a finished import does not hold the active-import guard', function () {
+ Activity::create([
+ 'log_name' => 'default',
+ 'description' => 'finished',
+ 'properties' => [
+ 'team_id' => $this->team->id,
+ 'type_uuid' => $this->database->uuid,
+ 'operation' => 'database_import',
+ 'status' => ProcessStatus::FINISHED->value,
+ ],
+ ]);
+
+ expect(fn () => startImport($this->database, $this->team->id))
+ ->toThrow(DatabaseImportException::class, 'The server path is invalid.');
+});
+
+test('a failed start releases the import lock', function () {
+ expect(fn () => startImport($this->database, $this->team->id))
+ ->toThrow(DatabaseImportException::class, 'The server path is invalid.');
+
+ $lock = Cache::lock(StartDatabaseImport::lockKey($this->database->uuid), StartDatabaseImport::LOCK_SECONDS);
+ try {
+ expect($lock->get())->toBeTrue();
+ } finally {
+ $lock->release();
+ }
+});
diff --git a/tests/Feature/StartDatabaseImportS3CredentialsTest.php b/tests/Feature/StartDatabaseImportS3CredentialsTest.php
new file mode 100644
index 0000000000..356a16cc07
--- /dev/null
+++ b/tests/Feature/StartDatabaseImportS3CredentialsTest.php
@@ -0,0 +1,104 @@
+set('cache.default', 'array');
+ config()->set('constants.ssh.mux_enabled', false);
+ InstanceSettings::forceCreate(['id' => 0]);
+
+ $this->team = Team::factory()->create();
+ $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ 'user' => 'root',
+ ]);
+ $this->destination = StandaloneDocker::firstOrCreate(
+ ['server_id' => $this->server->id, 'network' => 'coolify'],
+ ['uuid' => (string) Str::uuid(), 'name' => 'docker']
+ );
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+ $this->database = StandalonePostgresql::create([
+ 'uuid' => (string) Str::uuid(),
+ 'name' => 'db',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'db',
+ 'image' => 'postgres:17',
+ 'status' => 'running',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+});
+
+test('s3 import activity command does not contain storage key or secret', function () {
+ $accessKey = 'AKIA_TEST_ACCESS_KEY_LEAK';
+ $secret = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYTESTSECRET';
+ $storage = S3Storage::create([
+ 'name' => 'Import S3',
+ 'region' => 'us-east-1',
+ 'key' => $accessKey,
+ 'secret' => $secret,
+ 'bucket' => 'test-bucket',
+ 'endpoint' => 'https://8.8.8.8',
+ 'is_usable' => true,
+ 'team_id' => $this->team->id,
+ ]);
+
+ $disk = Mockery::mock(FilesystemAdapter::class);
+ $disk->shouldReceive('exists')->once()->with('backups/restore.sql')->andReturn(true);
+ $disk->shouldReceive('size')->once()->with('backups/restore.sql')->andReturn(1024);
+ $filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial();
+ $filesystem->shouldReceive('build')->once()->andReturn($disk);
+ Storage::swap($filesystem);
+
+ Process::fake();
+ Queue::fake();
+
+ $activity = app(StartDatabaseImport::class)->handle(
+ $this->database,
+ new DatabaseImportSource('s3', path: 'backups/restore.sql', s3StorageUuid: $storage->uuid),
+ $this->team->id,
+ );
+
+ $command = (string) $activity->getExtraProperty('command');
+
+ expect($command)
+ ->not->toContain($accessKey)
+ ->not->toContain($secret)
+ ->not->toContain('.env')
+ ->not->toContain('S3_ACCESS_KEY=')
+ ->not->toContain('S3_SECRET_KEY=')
+ ->toContain('mc alias set s3temp "$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY"');
+
+ Queue::assertPushed(CoolifyTask::class, function (CoolifyTask $job) {
+ $cleanup = $job->call_event_data;
+
+ return is_array($cleanup)
+ && ! array_key_exists('credentialTmpPath', $cleanup)
+ && filled($cleanup['containerName'] ?? null);
+ });
+});
diff --git a/tests/Feature/TablePaginationLoadingTest.php b/tests/Feature/TablePaginationLoadingTest.php
index 49bb974001..62142ffe57 100644
--- a/tests/Feature/TablePaginationLoadingTest.php
+++ b/tests/Feature/TablePaginationLoadingTest.php
@@ -1,6 +1,7 @@
');
@@ -30,6 +31,18 @@ it('renders the shared page size selector', function () {
->toContain('max="100"');
});
+it('disables page size controls when the gate denies access', function () {
+ Gate::define('view-audit-log-test', fn (): bool => false);
+
+ $html = Blade::render(<<<'BLADE'
+
+ BLADE);
+
+ expect($html)->toMatch('/]*aria-label="Items per page"[^>]*\sdisabled(?:[=\s>])/')
+ ->toMatch('/ ]*aria-label="Custom items per page"[^>]*\sdisabled(?:[=\s>])/');
+});
+
it('positions table dropdown panels outside overflowing containers', function () {
$html = Blade::render(<<<'BLADE'
diff --git a/tests/Feature/TeamInvitationUiTest.php b/tests/Feature/TeamInvitationUiTest.php
index 13b6de23e5..949a301923 100644
--- a/tests/Feature/TeamInvitationUiTest.php
+++ b/tests/Feature/TeamInvitationUiTest.php
@@ -51,27 +51,21 @@ it('renders a real copy button for pending invitation links', function () {
$view = file_get_contents(resource_path('views/livewire/team/invitations.blade.php'));
expect($view)
- ->toContain('aria-label="Copy invitation link"')
- ->toContain('window.copyToClipboard(@js($invite->link))')
- ->toContain('class="button h-7! shrink-0 px-2!"');
+ ->toContain(' ');
Livewire::test(Invitations::class, [
'invitations' => TeamInvitation::ownedByCurrentTeam()->get(),
])
->assertSee($invitation->link)
->assertSeeHtml('aria-label="Copy invitation link"')
- ->assertSeeHtml('window.copyToClipboard(')
+ ->assertSeeHtml('x-data="copyButton"')
->assertSeeHtml('type="button"');
});
-it('exposes a resilient global copyToClipboard helper', function () {
+it('keeps clipboard logic in the shared copy button instead of a global helper', function () {
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
- expect($layout)
- ->toContain('async function copyToClipboard(text)')
- ->toContain('window.copyToClipboard = copyToClipboard')
- ->toContain('document.execCommand(\'copy\')')
- ->toContain('window.isSecureContext');
+ expect($layout)->not->toContain('copyToClipboard');
});
it('preserves a provisional user when revoking their invitation fails', function () {
diff --git a/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php
new file mode 100644
index 0000000000..9008b87380
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php
@@ -0,0 +1,46 @@
+toBe('Desktop');
+ expect(deviceLabel('smartphone'))->toBe('Mobile');
+ expect(deviceLabel('mobilephone'))->toBe('Mobile');
+ expect(deviceLabel('crawler'))->toBe('Bot');
+ expect(deviceLabel('tablet'))->toBe('Tablet');
+ expect(deviceLabel(''))->toBe('Unknown');
+ expect(deviceLabel(null))->toBe('Unknown');
+});
+
+it('extracts a bare host from referer URLs and bare hosts, dropping www', function () {
+ expect(refererHost('https://www.google.com/search?q=x'))->toBe('google.com');
+ expect(refererHost('http://news.ycombinator.com'))->toBe('news.ycombinator.com');
+ expect(refererHost('example.com'))->toBe('example.com');
+ expect(refererHost(''))->toBeNull();
+ expect(refererHost(null))->toBeNull();
+});
+
+it('builds a duckduckgo favicon url for a host', function () {
+ expect(refererFaviconUrl('example.com'))->toBe('https://icons.duckduckgo.com/ip3/example.com.ico');
+});
+
+it('builds a flagcdn image url for valid ISO codes only', function () {
+ expect(countryFlagUrl('US'))->toBe('https://flagcdn.com/24x18/us.png');
+ expect(countryFlagUrl('de', '48x36'))->toBe('https://flagcdn.com/48x36/de.png');
+ expect(countryFlagUrl('ZZZ'))->toBeNull();
+ expect(countryFlagUrl(''))->toBeNull();
+ expect(countryFlagUrl(null))->toBeNull();
+});
+
+it('renders ampersands in breakdown empty-state descriptions', function () {
+ $html = view('livewire.traffic._breakdown-section', [
+ 'dimension' => 'agent',
+ 'label' => 'AI agents & bots',
+ 'rows' => [],
+ ])->render();
+
+ expect($html)
+ ->toContain('No ai agents & bots data for the selected range.')
+ ->not->toContain('No ai agents & bots data for the selected range.');
+});
diff --git a/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php
new file mode 100644
index 0000000000..1c551c8b21
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/ApplicationAnalyticsTest.php
@@ -0,0 +1,248 @@
+responses as $needle => $response) {
+ if (str_contains($url, $needle)) {
+ return $response;
+ }
+ }
+
+ return '{}';
+ }
+}
+
+function fakeAnalyticsResponses(): array
+{
+ return [
+ '/traffic/overview' => json_encode([
+ 'requests' => 1000,
+ 'bytes_in' => 5000,
+ 'bytes_out' => 25000,
+ 'status' => ['s2xx' => 900, 's3xx' => 50, 's4xx' => 40, 's5xx' => 10],
+ 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
+ 'unique_visitors' => 320,
+ ]),
+ '/traffic/paths' => json_encode([
+ ['path' => '/', 'app' => 'app-key', 'requests' => 500, 'bytes_out' => 12000, 'p50' => 10.0, 'p95' => 30.0],
+ ]),
+ '/traffic/breakdown/agent' => json_encode([
+ ['value' => 'ClaudeBot', 'requests' => 90, 'bytes_out' => 2000],
+ ]),
+ '/traffic/breakdown/ip' => json_encode([
+ ['value' => '198.51.100.42', 'requests' => 60, 'bytes_out' => 1200],
+ ]),
+ '/traffic/breakdown/useragent' => json_encode([
+ ['value' => 'Mozilla/5.0 (Macintosh) PerAppAgent/2.0', 'requests' => 55, 'bytes_out' => 1100],
+ ]),
+ '/traffic/breakdown/country' => json_encode([
+ ['value' => 'US', 'requests' => 600, 'bytes_out' => 15000],
+ ]),
+ '/traffic/breakdown/referer' => json_encode([
+ ['value' => 'google.com', 'requests' => 300, 'bytes_out' => 8000],
+ ]),
+ '/traffic/breakdown/browser' => json_encode([
+ ['value' => 'Chrome', 'requests' => 700, 'bytes_out' => 18000],
+ ]),
+ '/traffic/breakdown/os' => json_encode([
+ ['value' => 'macOS', 'requests' => 400, 'bytes_out' => 10000],
+ ]),
+ '/traffic/breakdown/device' => json_encode([
+ ['value' => 'Desktop', 'requests' => 800, 'bytes_out' => 20000],
+ ]),
+ '/traffic/series' => json_encode([
+ ['bucket' => 1_700_000_000_000, 's2xx' => 40, 's3xx' => 2, 's4xx' => 1, 's5xx' => 0, 'requests' => 43, 'bytes_in' => 1000, 'bytes_out' => 5000, 'unique_visitors' => 12, 'p95' => 30.0],
+ ['bucket' => 1_700_003_600_000, 's2xx' => 60, 's3xx' => 3, 's4xx' => 2, 's5xx' => 1, 'requests' => 66, 'bytes_in' => 1500, 'bytes_out' => 8000, 'unique_visitors' => 20, 'p95' => 45.0],
+ ]),
+ '/traffic/attribution' => json_encode(['attribution' => 'GeoIP data by MaxMind']),
+ ];
+}
+
+beforeEach(function () {
+ // RefreshDatabase resets ids each test; without flushing the model identity map a stale
+ // Server (from a prior enabled test) can leak into a later test and be treated as
+ // analytics-enabled, mounting the component against an unreachable server.
+ Server::flushIdentityMap();
+ InstanceSettings::forceCreate(['id' => 0]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function makeAnalyticsApplication(Team $team, PrivateKey $privateKey, Environment $environment, bool $enabled): Application
+{
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'private_key_id' => $privateKey->id,
+ ]);
+ $server->settings->is_traffic_analytics_enabled = $enabled;
+ $server->settings->save();
+
+ $destination = StandaloneDocker::where('server_id', $server->id)->first()
+ ?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
+
+ return Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+}
+
+it('only lazy loads application analytics when traffic analytics is enabled', function () {
+ $configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
+
+ expect($configuration)
+ ->toContain(':lazy="$application->destination?->server?->isTrafficAnalyticsEnabled()"');
+});
+
+it('renders the disabled state in the initial application analytics page response', function () {
+ $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, false);
+
+ $this->get(route('project.application.analytics', [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'application_uuid' => $application->uuid,
+ ]))
+ ->assertOk()
+ ->assertSee('Traffic analytics is not enabled')
+ ->assertDontSee('__lazyLoad', escape: false)
+ ->assertDontSee('analytics-range-section');
+});
+
+it('guards the lazy placeholder when traffic analytics is disabled', function () {
+ $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, false);
+
+ Livewire::test(Analytics::class, ['application' => $application, 'lazy' => true])
+ ->assertSee('Traffic analytics is not enabled')
+ ->assertDontSee('analytics-range-section');
+});
+
+it('renders KPIs from a mocked traffic client when analytics is enabled', function () {
+ $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true);
+
+ $fake = new FakeAnalyticsTrafficClient($application->destination->server);
+ $fake->responses = fakeAnalyticsResponses();
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class, ['application' => $application]))
+ ->assertOk()
+ ->assertSee('Requests')
+ ->assertSee('1,000')
+ ->assertSee('Unique visitors')
+ ->assertSee('Error rate')
+ ->assertSee('/')
+ ->assertSee('United States')
+ ->assertSee('GeoIP data by MaxMind');
+});
+
+it('loads the per-app status time series when Sentinel exposes the series endpoint', function () {
+ $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true);
+
+ $fake = new FakeAnalyticsTrafficClient($application->destination->server);
+ $fake->responses = fakeAnalyticsResponses();
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class, ['application' => $application]))
+ ->assertOk()
+ ->assertSet('hasSeries', true)
+ ->assertSet('series', [
+ ['bucket' => 1_700_000_000_000, 's2xx' => 40, 's3xx' => 2, 's4xx' => 1, 's5xx' => 0, 'requests' => 43, 'bytesIn' => 1000, 'bytesOut' => 5000, 'uniqueVisitors' => 12, 'p95' => 30.0],
+ ['bucket' => 1_700_003_600_000, 's2xx' => 60, 's3xx' => 3, 's4xx' => 2, 's5xx' => 1, 'requests' => 66, 'bytesIn' => 1500, 'bytesOut' => 8000, 'uniqueVisitors' => 20, 'p95' => 45.0],
+ ])
+ ->assertDispatched('refreshChartData-application-analytics-status');
+});
+
+it('decorates per-app paths with the app domain and surfaces AI agents', function () {
+ $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true);
+ $application->update(['fqdn' => 'https://api.example.com']);
+
+ $fake = new FakeAnalyticsTrafficClient($application->destination->server);
+ $fake->responses = fakeAnalyticsResponses();
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ $component = loadLazy(Livewire::test(Analytics::class, ['application' => $application]))
+ ->assertOk()
+ ->assertSee('api.example.com')
+ ->assertSee('AI agents & bots')
+ ->assertSee('ClaudeBot')
+ ->assertSee('Top IPs')
+ ->assertSee('198.51.100.42')
+ ->assertSee('Top user agents')
+ ->assertSee('PerAppAgent/2.0');
+
+ expect($component->instance()->topPaths[0]['domain'])->toBe('api.example.com');
+});
+
+it('falls back to the donut for the per-app chart when the series endpoint is absent', function () {
+ $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true);
+
+ $responses = fakeAnalyticsResponses();
+ unset($responses['/traffic/series']);
+
+ $fake = new FakeAnalyticsTrafficClient($application->destination->server);
+ $fake->responses = $responses;
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class, ['application' => $application]))
+ ->assertOk()
+ ->assertSet('hasSeries', false)
+ ->assertSet('series', []);
+});
+
+it('shows an empty state when traffic analytics is disabled for the server', function () {
+ $application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, false);
+
+ Livewire::test(Analytics::class, ['application' => $application, 'lazy' => false])
+ ->assertOk()
+ ->assertSee('Analytics')
+ ->assertSee('Traffic analytics is not enabled')
+ ->assertSee('Server analytics')
+ ->assertSeeHtml(route('server.analytics', ['server_uuid' => $application->destination->server->uuid]))
+ ->assertDontSee('__lazyLoad', escape: false)
+ ->assertDontSee('Unique visitors');
+});
+
+it('renders the disabled empty-state without crashing when the application has no destination', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => null,
+ 'destination_type' => null,
+ ]);
+
+ expect($application->destination)->toBeNull();
+
+ Livewire::test(Analytics::class, ['application' => $application, 'lazy' => false])
+ ->assertOk()
+ ->assertSee('Analytics')
+ ->assertSee('Traffic analytics is not enabled')
+ ->assertDontSee('__lazyLoad', escape: false)
+ ->assertDontSee('Server analytics');
+});
diff --git a/tests/Feature/TrafficAnalytics/ApplicationTrafficOverviewTest.php b/tests/Feature/TrafficAnalytics/ApplicationTrafficOverviewTest.php
new file mode 100644
index 0000000000..4c958f1a79
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/ApplicationTrafficOverviewTest.php
@@ -0,0 +1,121 @@
+responses as $needle => $response) {
+ if (str_contains($url, $needle)) {
+ return $response;
+ }
+ }
+
+ return '{}';
+ }
+}
+
+beforeEach(function () {
+ // Servers are memoized via once()/cache; clear both so DB-id reuse across tests doesn't bleed a stale enabled server.
+ Cache::flush();
+ Server::flushIdentityMap();
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+ $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+});
+
+function makeAppOnServer(bool $analyticsEnabled): Application
+{
+ $server = Server::factory()->create([
+ 'team_id' => test()->team->id,
+ 'private_key_id' => test()->privateKey->id,
+ ]);
+ $server->settings->is_traffic_analytics_enabled = $analyticsEnabled;
+ $server->settings->save();
+
+ $project = Project::factory()->create(['team_id' => test()->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $destination = StandaloneDocker::where('server_id', $server->id)->first()
+ ?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
+
+ return Application::factory()->create([
+ 'name' => 'Widget App',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+}
+
+it('shows last-24h KPIs and a link to full analytics when enabled with data', function () {
+ $application = makeAppOnServer(true);
+
+ app()->bind(SentinelTrafficClient::class, function ($app, $params) {
+ $client = new FakeAppOverviewTrafficClient($params['server']);
+ $client->responses = [
+ '/traffic/overview' => json_encode([
+ 'requests' => 4200,
+ 'bytes_in' => 5000,
+ 'bytes_out' => 25000,
+ 'status' => ['s2xx' => 4000, 's3xx' => 100, 's4xx' => 80, 's5xx' => 20],
+ 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
+ 'unique_visitors' => 1234,
+ ]),
+ ];
+
+ return $client;
+ });
+
+ $component = loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application]));
+ $component->assertOk()
+ ->assertSet('enabled', true)
+ ->assertSee('Traffic (last 24h)')
+ ->assertSee('4,200')
+ ->assertSee('1,234')
+ ->assertSee('View full analytics');
+});
+
+it('shows the muted no-data note when enabled but no traffic recorded', function () {
+ $application = makeAppOnServer(true);
+
+ app()->bind(SentinelTrafficClient::class, function ($app, $params) {
+ $client = new FakeAppOverviewTrafficClient($params['server']);
+ $client->responses = ['/traffic/overview' => json_encode(['requests' => 0])];
+
+ return $client;
+ });
+
+ loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application]))
+ ->assertOk()
+ ->assertSee('No traffic recorded in the last 24h yet');
+});
+
+it('shows the enable nudge when analytics is disabled on an eligible server', function () {
+ $application = makeAppOnServer(false);
+
+ loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application]))
+ ->assertOk()
+ ->assertSet('enabled', false)
+ ->assertSee('Traffic analytics')
+ ->assertSee('Server settings')
+ ->assertDontSee('Traffic (last 24h)');
+});
diff --git a/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php b/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php
new file mode 100644
index 0000000000..8ae029cb63
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/CaddyProxyVolumeTest.php
@@ -0,0 +1,48 @@
+create();
+ $this->team = $user->teams()->first();
+
+ $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+});
+
+it('does not mount the traffic volume for caddy when traffic analytics is disabled', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
+ $server->proxy->set('type', 'CADDY');
+ $server->save();
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ $config = Yaml::parse(generateDefaultProxyConfiguration($server->fresh()));
+
+ expect($config['services']['caddy']['volumes'])
+ ->not->toContain($server->proxyPath().':/traffic');
+});
+
+it('mounts the traffic volume for caddy when traffic analytics is enabled', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
+ $server->proxy->set('type', 'CADDY');
+ $server->save();
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+
+ $config = Yaml::parse(generateDefaultProxyConfiguration($server->fresh()));
+
+ expect($config['services']['caddy']['volumes'])
+ ->toContain($server->proxyPath().':/traffic');
+});
diff --git a/tests/Feature/TrafficAnalytics/CaddyTrafficLabelsTest.php b/tests/Feature/TrafficAnalytics/CaddyTrafficLabelsTest.php
new file mode 100644
index 0000000000..e5a8c3dc24
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/CaddyTrafficLabelsTest.php
@@ -0,0 +1,25 @@
+filter(fn ($l) => str_contains($l, 'log_append'))->isEmpty())->toBeTrue();
+});
+
+it('stamps coolify_app_id and JSON access log on each caddy site when enabled', function () {
+ $labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), is_traffic_analytics_enabled: true);
+ expect($labels->contains('caddy_0.log_append=coolify_app_id app-uuid'))->toBeTrue();
+ expect($labels->contains('caddy_0.log.output=file /traffic/access.log'))->toBeTrue();
+ expect($labels->contains('caddy_0.log.format=json'))->toBeTrue();
+});
+
+it('emits lumberjack roll directives on each caddy site when enabled', function () {
+ $labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), is_traffic_analytics_enabled: true);
+ expect($labels->contains('caddy_0.log.output.roll_size=20MiB'))->toBeTrue();
+ expect($labels->contains('caddy_0.log.output.roll_keep=5'))->toBeTrue();
+ expect($labels->contains('caddy_0.log.output.roll_keep_for=168h'))->toBeTrue();
+});
+
+it('omits lumberjack roll directives when disabled', function () {
+ $labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), is_traffic_analytics_enabled: false);
+ expect($labels->filter(fn ($l) => str_contains($l, 'roll_'))->isEmpty())->toBeTrue();
+});
diff --git a/tests/Feature/TrafficAnalytics/ChartTokensTest.php b/tests/Feature/TrafficAnalytics/ChartTokensTest.php
new file mode 100644
index 0000000000..8406b34e24
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/ChartTokensTest.php
@@ -0,0 +1,208 @@
+toContain($token);
+ }
+
+ // Dark overrides must exist so the palette is a selected dark theme, not a flip.
+ expect($css)->toContain('.dark {');
+});
+
+it('no longer hardcodes status color hex arrays in the analytics views', function () {
+ $views = [
+ base_path('resources/views/livewire/analytics.blade.php'),
+ base_path('resources/views/livewire/project/application/analytics.blade.php'),
+ ];
+
+ foreach ($views as $view) {
+ $contents = file_get_contents($view);
+
+ expect($contents)->not->toContain('statusColorsLight');
+ expect($contents)->not->toContain('statusColorsDark');
+ // The chart lives in the shared partial; the views just pull it in.
+ expect($contents)->toContain("@include('livewire.traffic._requests-chart')");
+ }
+});
+
+it('reads its accent color from a design token in the shared requests-chart partial', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_requests-chart.blade.php'));
+
+ expect($partial)->not->toContain('statusColorsLight');
+ expect($partial)->not->toContain('statusColorsDark');
+ expect($partial)->toContain('--chart-status-3xx');
+});
+
+it('keeps request values in a compact y-axis outside the plot', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_requests-chart.blade.php'));
+
+ expect($partial)
+ ->toContain('minWidth: 28')
+ ->toContain('maxWidth: 28')
+ ->toContain('padding: { left: 6, right: 12, top: 12, bottom: 0 }')
+ ->not->toContain('floating: true')
+ ->not->toContain('offsetX: 32');
+});
+
+it('renders request charts full bleed inside their analytics sections', function () {
+ foreach ([
+ base_path('resources/views/livewire/analytics.blade.php'),
+ base_path('resources/views/livewire/project/application/analytics.blade.php'),
+ ] as $viewPath) {
+ $view = file_get_contents($viewPath);
+
+ expect($view)->toContain('id="analytics-requests-section" title="Requests" flush');
+ }
+});
+
+it('blends KPI rows into their analytics section surface', function () {
+ foreach ([
+ base_path('resources/views/livewire/analytics.blade.php'),
+ base_path('resources/views/livewire/project/application/analytics.blade.php'),
+ ] as $viewPath) {
+ $view = file_get_contents($viewPath);
+
+ expect($view)
+ ->toContain('bg-[var(--coollabs-base)]')
+ ->not->toContain('bg-white px-4 py-3 dark:bg-base');
+ }
+});
+
+it('keeps the analytics KPI skeleton the same height and surface as loaded tiles', function () {
+ $view = file_get_contents(base_path('resources/views/components/skeleton/tiles.blade.php'));
+
+ expect($view)
+ ->toContain('bg-[var(--coollabs-base)]')
+ ->toContain("'Requests', 'Unique visitors', 'Bandwidth', 'Error rate', 'p95 latency'")
+ ->toContain('tracking-wide text-neutral-500 uppercase dark:text-fg-dim')
+ ->not->toContain('h-3 w-16')
+ ->toContain('h-9 w-full rounded')
+ ->not->toContain('dark:bg-base');
+});
+
+it('seeds the requests chart with the server-rendered series', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_requests-chart.blade.php'));
+
+ expect($partial)
+ ->toContain("'initialCategories' => array_column(\$series, 'bucket')")
+ ->toContain("'initialRequests' => \$this->requestsSpark()")
+ ->toContain('series: [{ name: \'Requests\', data: initialPoints }]');
+});
+
+it('waits for lazy Livewire chart elements before initializing ApexCharts', function () {
+ foreach ([
+ base_path('resources/views/livewire/traffic/_requests-chart.blade.php'),
+ base_path('resources/views/livewire/traffic/_device-chart.blade.php'),
+ ] as $partialPath) {
+ $partial = file_get_contents($partialPath);
+
+ expect($partial)
+ ->toContain('requestAnimationFrame(() => {')
+ ->toContain('if (!el) { return; }');
+ }
+});
+
+it('treats an all-zero device series as no data', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_device-chart.blade.php'));
+
+ expect($partial)
+ ->toContain("\$hasDeviceData = array_sum(array_map('intval', \$series)) > 0")
+ ->toContain('@if (! $hasDeviceData)');
+});
+
+it('renders the device chart tooltip with the shared opaque background', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_device-chart.blade.php'));
+
+ expect($partial)
+ ->toContain('apexcharts-tooltip-custom')
+ ->toContain('apexcharts-tooltip-custom-value');
+});
+
+it('keeps KPI sparklines axisless after live updates', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_sparkline.blade.php'));
+
+ expect($partial)
+ ->toContain('yaxis: { labels: { show: false }')
+ ->toContain("xaxis: { type: 'datetime', labels: { show: false }, axisBorder: { show: false }, axisTicks: { show: false } }")
+ ->toContain('grid: { padding: { left: 4, right: 4, top: 0, bottom: 0 } }');
+});
+
+it('shows values with local and UTC timestamps in analytics chart tooltips', function () {
+ $sparkline = file_get_contents(base_path('resources/views/livewire/traffic/_sparkline.blade.php'));
+ $requestsChart = file_get_contents(base_path('resources/views/livewire/traffic/_requests-chart.blade.php'));
+
+ expect($sparkline)
+ ->toContain('sparkCategories')
+ ->toContain('apexcharts-tooltip-custom-value')
+ ->toContain('formatLocalTimestamp(timestamp)')
+ ->toContain('formatUtcTimestamp(timestamp)')
+ ->toContain('Your time:')
+ ->toContain('UTC:')
+ ->toContain("timeZoneName: 'short'")
+ ->toContain("timeZone: 'UTC'");
+
+ expect($requestsChart)
+ ->toContain('apexcharts-tooltip-custom-value')
+ ->toContain('formatLocalTimestamp(timestamp)')
+ ->toContain('formatUtcTimestamp(timestamp)')
+ ->toContain('Your time:')
+ ->toContain('UTC:')
+ ->toContain("timeZoneName: 'short'")
+ ->toContain("timeZone: 'UTC'");
+
+ foreach ([
+ app_path('Livewire/Analytics.php'),
+ app_path('Livewire/Project/Application/Analytics.php'),
+ app_path('Livewire/Dashboard/TrafficAnalytics.php'),
+ ] as $component) {
+ expect(file_get_contents($component))->toContain("'sparkCategories' => array_column(\$this->series, 'bucket')");
+ }
+});
+
+it('registers sparkline refresh listeners without Blade control-flow inside Alpine data', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_sparkline.blade.php'));
+
+ expect($partial)
+ ->not->toContain('@if ($event && $key)')
+ ->toContain('if (event && key)')
+ ->toContain('this.refreshCleanup = Livewire.on(event')
+ ->toContain('destroy()');
+});
+
+it('lets KPI hover markers overflow without shrinking the chart scale', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_sparkline.blade.php'));
+
+ expect($partial)
+ ->toContain('[&_.apexcharts-svg]:overflow-visible!')
+ ->not->toContain('headroom')
+ ->toContain('markers: { size: 0, hover: { size: 5, sizeOffset: 2 } }');
+});
+
+it('uses the standard page title styling in the analytics view and placeholder', function () {
+ foreach ([
+ base_path('resources/views/livewire/analytics.blade.php'),
+ base_path('resources/views/livewire/analytics-placeholder.blade.php'),
+ ] as $view) {
+ $contents = file_get_contents($view);
+
+ expect($contents)
+ ->toContain('Analytics ')
+ ->not->toContain('create();
+ $this->team = $user->teams()->first();
+});
+
+it('enables analytics, regenerates proxy config and recreates sentinel', function () {
+ Queue::fake();
+ StartSentinel::partialMock()->shouldReceive('handle')->atLeast()->once();
+ GetProxyConfiguration::partialMock()->shouldReceive('handle')->atLeast()->once();
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ ConfigureTrafficAnalytics::run($server, true);
+
+ expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue();
+ Queue::assertPushed(RestartProxyJob::class);
+});
+
+it('disables analytics', function () {
+ Queue::fake();
+ StartSentinel::partialMock()->shouldReceive('handle')->atLeast()->once();
+ GetProxyConfiguration::partialMock()->shouldReceive('handle')->atLeast()->once();
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ ConfigureTrafficAnalytics::run($server, true);
+ ConfigureTrafficAnalytics::run($server, false);
+
+ expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
+});
diff --git a/tests/Feature/TrafficAnalytics/DashboardTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/DashboardTrafficAnalyticsTest.php
new file mode 100644
index 0000000000..76a3c5b7ed
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/DashboardTrafficAnalyticsTest.php
@@ -0,0 +1,236 @@
+responses as $needle => $response) {
+ if (str_contains($url, $needle)) {
+ return $response;
+ }
+ }
+
+ return '{}';
+ }
+}
+
+class FailingDashboardTrafficClient extends SentinelTrafficClient
+{
+ protected function raw(string $url): string
+ {
+ throw new RuntimeException('Server unreachable');
+ }
+}
+
+function fakeDashboardTrafficResponses(int $requests = 1000): array
+{
+ return [
+ '/traffic/apps' => json_encode([]),
+ '/traffic/overview' => json_encode([
+ 'requests' => $requests,
+ 'bytes_in' => 5000,
+ 'bytes_out' => 25000,
+ 'status' => ['s2xx' => 900, 's3xx' => 50, 's4xx' => 40, 's5xx' => 10],
+ 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
+ 'unique_visitors' => 320,
+ ]),
+ '/traffic/breakdown/country' => json_encode([
+ ['value' => 'US', 'requests' => 600, 'bytes_out' => 15000],
+ ]),
+ ];
+}
+
+beforeEach(function () {
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+});
+
+it('hides traffic analytics from the dashboard when no server has it enabled', function () {
+ $server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ Livewire::test(Dashboard::class)
+ ->assertOk()
+ ->assertDontSee('Traffic analytics');
+});
+
+it('renders the team traffic summary aggregated across servers with an approximate badge', function () {
+ $serverOne = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+ $serverOne->settings->is_traffic_analytics_enabled = true;
+ $serverOne->settings->save();
+
+ $serverTwo = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+ $serverTwo->settings->is_traffic_analytics_enabled = true;
+ $serverTwo->settings->save();
+
+ $fakeOne = new FakeDashboardTrafficClient($serverOne);
+ $fakeOne->responses = fakeDashboardTrafficResponses(1000);
+
+ $fakeTwo = new FakeDashboardTrafficClient($serverTwo);
+ $fakeTwo->responses = fakeDashboardTrafficResponses(500);
+
+ app()->bind(SentinelTrafficClient::class, function ($app, $params) use ($serverOne, $fakeOne, $fakeTwo) {
+ $server = $params['server'] ?? null;
+
+ return $server && $server->is($serverOne) ? $fakeOne : $fakeTwo;
+ });
+
+ loadLazy(Livewire::test(TrafficAnalytics::class))
+ ->assertOk()
+ ->assertSee('Requests')
+ ->assertSee('1,500')
+ ->assertSee('Unique visitors')
+ ->assertSee('approximate');
+});
+
+it('shows only sparkline KPI cards that link through to the full analytics page', function () {
+ $server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+
+ $otherTeam = Team::factory()->create();
+ $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
+ $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
+ $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
+ $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id, 'network' => 'other-team-test']);
+
+ $otherTeamApplication = Application::factory()->create([
+ 'name' => 'Secret Other Team App',
+ 'environment_id' => $otherEnvironment->id,
+ 'destination_id' => $otherDestination->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+
+ $fake = new FakeDashboardTrafficClient($server);
+ $fake->responses = fakeDashboardTrafficResponses(1000);
+ $fake->responses['/traffic/apps'] = json_encode([$otherTeamApplication->uuid]);
+
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(TrafficAnalytics::class))
+ ->assertOk()
+ // The slimmed dashboard shows only KPI cards + a link to /analytics β no per-app rows.
+ ->assertSee('Open analytics')
+ ->assertSee(route('analytics'), false)
+ ->assertDontSee('Top applications')
+ ->assertDontSee('Secret Other Team App')
+ ->assertDontSee($otherTeamApplication->uuid);
+});
+
+it('shows loading states while the dashboard range refreshes', function () {
+ $view = file_get_contents(resource_path('views/livewire/dashboard/traffic-analytics.blade.php'));
+
+ expect($view)
+ ->toContain('wire:loading.attr="disabled" wire:target="setRange"')
+ ->toContain('wire:loading.class="invisible" wire:target="setRange(\'24h\')"')
+ ->toContain('wire:loading wire:target="setRange(\'7d\')"')
+ ->toContain('wire:loading wire:target="setRange(\'30d\')"')
+ ->toContain('aria-label="Loading analytics"');
+});
+
+it('styles the open analytics link as a dashboard action button', function () {
+ $view = file_get_contents(resource_path('views/livewire/dashboard/traffic-analytics.blade.php'));
+
+ expect($view)
+ ->toContain('class="group inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5')
+ ->toContain('group-hover:translate-x-0.5');
+});
+
+it('uses the dashboard surface treatment for the analytics KPI group', function () {
+ $view = file_get_contents(resource_path('views/livewire/dashboard/traffic-analytics.blade.php'));
+
+ expect($view)
+ ->toContain('rounded-xl border border-neutral-200 bg-neutral-200')
+ ->toContain('dark:border-white/[0.08] dark:bg-white/[0.07]')
+ ->toContain('dark:bg-[color-mix(in_srgb,var(--color-app)_95%,white)]')
+ ->toContain('dark:hover:bg-[color-mix(in_srgb,var(--color-app)_93%,white)]')
+ ->not->toContain('rounded-xl bg-neutral-200 ring-1 ring-neutral-200')
+ ->not->toContain('dark:bg-base dark:hover:bg-white/[0.03]');
+});
+
+it('hides dashboard analytics when every server fetch fails', function () {
+ $serverOne = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+ $serverOne->settings->is_traffic_analytics_enabled = true;
+ $serverOne->settings->save();
+
+ $serverTwo = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+ $serverTwo->settings->is_traffic_analytics_enabled = true;
+ $serverTwo->settings->save();
+
+ app()->bind(SentinelTrafficClient::class, function ($app, $params) {
+ return new FailingDashboardTrafficClient($params['server']);
+ });
+
+ loadLazy(Livewire::test(TrafficAnalytics::class))
+ ->assertOk()
+ ->assertSeeHtml('class="contents"')
+ ->assertDontSee('Traffic analytics')
+ ->assertDontSee('No analytics data yet')
+ ->assertDontSee('Unique visitors')
+ ->assertDontSee('Error rate');
+});
+
+it('renders nothing when no server in the team has traffic analytics enabled', function () {
+ $server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+ // New servers default analytics on; this scenario is the all-disabled team.
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ loadLazy(Livewire::test(TrafficAnalytics::class))
+ ->assertOk()
+ ->assertDontSee('Unique visitors')
+ ->assertDontSee('Traffic analytics');
+});
+
+it('uses an empty lazy placeholder so analytics only appears after data loads', function () {
+ $view = file_get_contents(resource_path('views/livewire/dashboard/traffic-analytics-placeholder.blade.php'));
+
+ expect($view)
+ ->toContain('class="contents"')
+ ->not->toContain('Traffic analytics');
+});
diff --git a/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php b/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php
new file mode 100644
index 0000000000..bf239a743c
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php
@@ -0,0 +1,116 @@
+responses as $needle => $response) {
+ if (str_contains($url, $needle)) {
+ return $response;
+ }
+ }
+
+ return '{}';
+ }
+}
+
+function fakeGeoResponses(array $countryRows): array
+{
+ return [
+ '/traffic/apps' => json_encode([]),
+ '/traffic/overview' => json_encode([
+ 'requests' => 1000,
+ 'bytes_in' => 5000,
+ 'bytes_out' => 25000,
+ 'status' => ['s2xx' => 900, 's3xx' => 50, 's4xx' => 40, 's5xx' => 10],
+ 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
+ 'unique_visitors' => 320,
+ ]),
+ '/traffic/paths' => json_encode([]),
+ '/traffic/breakdown/country' => json_encode($countryRows),
+ '/traffic/attribution' => json_encode(['attribution' => 'GeoIP data by MaxMind']),
+ ];
+}
+
+function bootGeoServer(): Server
+{
+ Server::flushIdentityMap();
+ $team = Team::factory()->create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+ test()->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'private_key_id' => $privateKey->id,
+ ]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+
+ return $server;
+}
+
+it('renders resolved country names and the interactive globe when country data is present', function () {
+ $server = bootGeoServer();
+
+ $fake = new FakeGeoTrafficClient($server);
+ $fake->responses = fakeGeoResponses([
+ ['value' => 'US', 'requests' => 600, 'bytes_out' => 15000],
+ ['value' => 'DE', 'requests' => 200, 'bytes_out' => 6000],
+ ]);
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSee('Countries')
+ ->assertSee('United States')
+ ->assertSee('Germany')
+ // The interactive WebGL globe canvas replaces the old inline SVG choropleth.
+ ->assertSeeHtml('id="global-analytics-globe"')
+ ->assertSee('GeoIP data by MaxMind');
+});
+
+it('collapses unresolvable country codes into a single Unknown row', function () {
+ $server = bootGeoServer();
+
+ $fake = new FakeGeoTrafficClient($server);
+ $fake->responses = fakeGeoResponses([
+ ['value' => 'US', 'requests' => 600, 'bytes_out' => 15000],
+ ['value' => '', 'requests' => 50, 'bytes_out' => 500],
+ ['value' => 'ZZ', 'requests' => 25, 'bytes_out' => 250],
+ ]);
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSee('United States')
+ ->assertSee('Unknown');
+});
+
+it('shows a plain no-data state when no country data has been recorded', function () {
+ $server = bootGeoServer();
+
+ $fake = new FakeGeoTrafficClient($server);
+ $fake->responses = fakeGeoResponses([]);
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSee('Countries')
+ ->assertSee('No country data for the selected range');
+});
diff --git a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php
new file mode 100644
index 0000000000..c0a13000f0
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php
@@ -0,0 +1,435 @@
+responses as $needle => $response) {
+ if (str_contains($url, $needle)) {
+ return $response;
+ }
+ }
+
+ return '{}';
+ }
+}
+
+function fakeGlobalAnalyticsResponses(array $appUuids = []): array
+{
+ return [
+ '/traffic/apps' => json_encode($appUuids),
+ '/traffic/overview' => json_encode([
+ 'requests' => 1000,
+ 'bytes_in' => 5000,
+ 'bytes_out' => 25000,
+ 'status' => ['s2xx' => 900, 's3xx' => 50, 's4xx' => 40, 's5xx' => 10],
+ 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
+ 'unique_visitors' => 320,
+ ]),
+ '/traffic/paths' => json_encode([
+ ['path' => '/', 'app' => $appUuids[0] ?? '', 'requests' => 500, 'bytes_out' => 12000, 's4xx' => 3, 's5xx' => 1, 'p50' => 10.0, 'p95' => 30.0],
+ ]),
+ '/traffic/breakdown/agent' => json_encode([
+ ['value' => 'GPTBot', 'requests' => 120, 'bytes_out' => 3000],
+ ]),
+ '/traffic/breakdown/ip' => json_encode([
+ ['value' => '203.0.113.7', 'requests' => 80, 'bytes_out' => 2000],
+ ]),
+ '/traffic/breakdown/useragent' => json_encode([
+ ['value' => 'Mozilla/5.0 (X11; Linux x86_64) TestAgent/1.0', 'requests' => 70, 'bytes_out' => 1500],
+ ]),
+ '/traffic/breakdown/country' => json_encode([
+ ['value' => 'US', 'requests' => 600, 'bytes_out' => 15000],
+ ]),
+ '/traffic/breakdown/referer' => json_encode([
+ ['value' => 'google.com', 'requests' => 300, 'bytes_out' => 8000],
+ ]),
+ '/traffic/breakdown/browser' => json_encode([
+ ['value' => 'Chrome', 'requests' => 700, 'bytes_out' => 18000],
+ ]),
+ '/traffic/breakdown/os' => json_encode([
+ ['value' => 'macOS', 'requests' => 400, 'bytes_out' => 10000],
+ ]),
+ '/traffic/breakdown/device' => json_encode([
+ ['value' => 'Desktop', 'requests' => 800, 'bytes_out' => 20000],
+ ]),
+ '/traffic/breakdown/protocol' => json_encode([
+ ['value' => 'HTTP/2', 'requests' => 700, 'bytes_out' => 18000],
+ ['value' => 'HTTP/1.1', 'requests' => 300, 'bytes_out' => 8000],
+ ]),
+ '/traffic/breakdown/cache' => json_encode([
+ ['value' => 'hit', 'requests' => 400, 'bytes_out' => 9000],
+ ]),
+ '/traffic/breakdown/status' => json_encode([
+ ['value' => '200', 'requests' => 900, 'bytes_out' => 22000],
+ ]),
+ '/traffic/series' => json_encode([
+ ['bucket' => 1_700_000_000_000, 's2xx' => 40, 's3xx' => 2, 's4xx' => 1, 's5xx' => 0, 'requests' => 43, 'bytes_in' => 1000, 'bytes_out' => 5000, 'unique_visitors' => 12, 'p95' => 30.0],
+ ['bucket' => 1_700_003_600_000, 's2xx' => 60, 's3xx' => 3, 's4xx' => 2, 's5xx' => 1, 'requests' => 66, 'bytes_in' => 1500, 'bytes_out' => 8000, 'unique_visitors' => 20, 'p95' => 45.0],
+ ]),
+ '/traffic/attribution' => json_encode(['attribution' => 'GeoIP data by MaxMind']),
+ ];
+}
+
+beforeEach(function () {
+ Server::flushIdentityMap();
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+ $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+});
+
+function bootEnabledGlobalServer(): Server
+{
+ $server = Server::factory()->create([
+ 'team_id' => test()->team->id,
+ 'private_key_id' => test()->privateKey->id,
+ ]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+
+ return $server;
+}
+
+it('renders a team-wide analytics summary across enabled servers', function () {
+ $server = bootEnabledGlobalServer();
+
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $destination = StandaloneDocker::where('server_id', $server->id)->first()
+ ?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
+
+ $application = Application::factory()->create([
+ 'name' => 'Global Leaderboard App',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+
+ $fake = new FakeGlobalAnalyticsTrafficClient($server);
+ $fake->responses = fakeGlobalAnalyticsResponses([$application->uuid]);
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSee('Analytics')
+ ->assertSee('1,000')
+ ->assertSee('Top applications')
+ ->assertSee('Global Leaderboard App')
+ ->assertSee('Top hosts')
+ ->assertSee('Top paths')
+ ->assertSee('3 4xx')
+ ->assertSee('1 5xx')
+ ->assertSee('Status codes')
+ ->assertSee('Countries')
+ ->assertSee('United States')
+ // New breakdown sections surfaced from previously-unused Sentinel dimensions.
+ ->assertSee('Requests by device type')
+ ->assertSee('Top HTTP versions')
+ ->assertSee('HTTP/2')
+ ->assertSee('Top cache statuses')
+ ->assertSee('Top status codes')
+ ->assertSee('GeoIP data by MaxMind')
+ ->assertSet('serverOptions', [$server->uuid => $server->name])
+ ->assertSet('appOptions', [$application->uuid => 'Global Leaderboard App']);
+});
+
+it('shows loading states while analytics filters refresh', function () {
+ $view = file_get_contents(resource_path('views/livewire/analytics.blade.php'));
+
+ expect($view)
+ ->toContain('wire:loading.class="pointer-events-none opacity-60" wire:target="serverUuid"')
+ ->toContain('wire:loading.flex wire:target="serverUuid"')
+ ->toContain('wire:loading.class="pointer-events-none opacity-60" wire:target="appUuid"')
+ ->toContain('wire:loading.flex wire:target="appUuid"')
+ ->toContain('wire:loading.attr="disabled" wire:target="setRange"')
+ ->toContain('wire:loading.class="invisible" wire:target="setRange(\'24h\')"')
+ ->toContain('wire:loading wire:target="setRange(\'7d\')"')
+ ->toContain('wire:loading wire:target="setRange(\'30d\')"')
+ ->toContain('aria-label="Loading analytics"');
+});
+
+it('shows a no-data state for the requests chart when no traffic falls in the range', function () {
+ $server = bootEnabledGlobalServer();
+
+ $responses = fakeGlobalAnalyticsResponses();
+ // Zeroed overview + no series buckets: the page still renders, but there is nothing
+ // to plot over time, so the Requests chart shows its no-data overlay.
+ $responses['/traffic/overview'] = json_encode([
+ 'requests' => 0, 'bytes_in' => 0, 'bytes_out' => 0,
+ 'status' => ['s2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0],
+ 'latency' => ['p50' => 0, 'p95' => 0, 'p99' => 0], 'unique_visitors' => 0,
+ ]);
+ $responses['/traffic/series'] = json_encode([]);
+
+ $fake = new FakeGlobalAnalyticsTrafficClient($server);
+ $fake->responses = $responses;
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSee('No requests in this range')
+ // Server-rendered as visible (display:flex) before any client-side toggle.
+ ->assertSee('global-analytics-requests-empty" style="display: flex', escape: false);
+});
+
+it('hides the requests no-data overlay when there is traffic in the range', function () {
+ $server = bootEnabledGlobalServer();
+
+ $fake = new FakeGlobalAnalyticsTrafficClient($server);
+ $fake->responses = fakeGlobalAnalyticsResponses();
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSee('global-analytics-requests-empty" style="display: none', escape: false);
+});
+
+it('renders the full analytics page with a lazy placeholder before data loads', function () {
+ InstanceSettings::forceCreate(['id' => 0]);
+ bootEnabledGlobalServer();
+
+ // Full-page #[Lazy]: the initial HTTP response is the skeleton placeholder plus the
+ // x-intersect __lazyLoad trigger; the Sentinel round-trips run only on the deferred call.
+ $this->get(route('analytics'))
+ ->assertOk()
+ ->assertSee('Analytics')
+ ->assertSee('__lazyLoad', escape: false);
+});
+
+it('shows path domains, links top apps to analytics, groups by project, and surfaces AI agents', function () {
+ $server = bootEnabledGlobalServer();
+
+ $project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Storefront']);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $destination = StandaloneDocker::where('server_id', $server->id)->first()
+ ?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
+
+ $application = Application::factory()->create([
+ 'name' => 'Shop',
+ 'fqdn' => 'https://shop.example.com',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+
+ $fake = new FakeGlobalAnalyticsTrafficClient($server);
+ $fake->responses = fakeGlobalAnalyticsResponses([$application->uuid]);
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ $analyticsUrl = route('project.application.analytics', [
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ 'application_uuid' => $application->uuid,
+ ]);
+
+ $component = loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSee('Top hosts')
+ ->assertSee('shop.example.com') // served host shown in Top hosts + top-app row
+ ->assertSee($analyticsUrl, false) // top-app row links to its analytics page
+ ->assertSee('AI agents & bots')
+ ->assertSee('GPTBot')
+ ->assertSee('Top IPs')
+ ->assertSee('203.0.113.7')
+ ->assertSee('Top user agents')
+ ->assertSee('TestAgent/1.0');
+
+ // Path rows carry the resolved domain, top-app rows carry the domain + analytics link.
+ expect($component->instance()->topPaths[0]['domain'])->toBe('shop.example.com');
+ expect($component->instance()->topPaths[0]['s4xx'])->toBe(3);
+ expect($component->instance()->topPaths[0]['s5xx'])->toBe(1);
+ expect($component->instance()->topApps[0]['domain'])->toBe('shop.example.com');
+ expect($component->instance()->topApps[0]['link'])->toBe($analyticsUrl);
+
+ // The application listbox is grouped under a project header.
+ $grouped = $component->instance()->appGroupedOptions;
+ expect(collect($grouped)->firstWhere('header', true))->not->toBeNull();
+ expect(collect($grouped)->firstWhere('label', 'Storefront')['header'] ?? null)->toBeTrue();
+ expect(collect($grouped)->firstWhere('value', $application->uuid)['label'])->toBe('Shop');
+});
+
+it('builds a stacked status time series when Sentinel exposes the series endpoint', function () {
+ $server = bootEnabledGlobalServer();
+
+ $fake = new FakeGlobalAnalyticsTrafficClient($server);
+ $fake->responses = fakeGlobalAnalyticsResponses();
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSet('hasSeries', true)
+ ->assertSet('series', [
+ ['bucket' => 1_700_000_000_000, 's2xx' => 40, 's3xx' => 2, 's4xx' => 1, 's5xx' => 0, 'requests' => 43, 'bytesIn' => 1000, 'bytesOut' => 5000, 'uniqueVisitors' => 12, 'p95' => 30.0],
+ ['bucket' => 1_700_003_600_000, 's2xx' => 60, 's3xx' => 3, 's4xx' => 2, 's5xx' => 1, 'requests' => 66, 'bytesIn' => 1500, 'bytesOut' => 8000, 'uniqueVisitors' => 20, 'p95' => 45.0],
+ ])
+ ->assertDispatched('refreshChartData-global-analytics-status');
+});
+
+it('derives KPI sparklines, device-donut data, and top hosts for the chart payload', function () {
+ $server = bootEnabledGlobalServer();
+
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $destination = StandaloneDocker::where('server_id', $server->id)->first()
+ ?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
+
+ $application = Application::factory()->create([
+ 'name' => 'Sparkline App',
+ 'fqdn' => 'https://spark.example.com',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+
+ $fake = new FakeGlobalAnalyticsTrafficClient($server);
+ $fake->responses = fakeGlobalAnalyticsResponses([$application->uuid]);
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ $instance = loadLazy(Livewire::test(Analytics::class))->assertOk()->instance();
+
+ // Per-bucket sparkline series derived from Sentinel's enriched buckets.
+ expect($instance->requestsSpark())->toBe([43, 66]);
+ expect($instance->errorsSpark())->toBe([1, 3]);
+ expect($instance->bandwidthSpark())->toBe([6000, 9500]);
+ expect($instance->uniquesSpark())->toBe([12, 20]);
+ expect($instance->latencySpark())->toBe([30.0, 45.0]);
+
+ // Device breakdown folds into donut labels/series.
+ $device = $instance->deviceChartData();
+ expect($device['series'])->toBe([800]);
+
+ // Top hosts groups per-app volume by served hostname.
+ expect($instance->topHosts[0]['host'])->toBe('spark.example.com');
+ expect($instance->topHosts[0]['requests'])->toBe(1000);
+});
+
+it('falls back to the donut when Sentinel lacks the series endpoint', function () {
+ $server = bootEnabledGlobalServer();
+
+ // Same responses minus the series entry β an older Sentinel returns 404 (empty body).
+ $responses = fakeGlobalAnalyticsResponses();
+ unset($responses['/traffic/series']);
+
+ $fake = new FakeGlobalAnalyticsTrafficClient($server);
+ $fake->responses = $responses;
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSet('hasSeries', false)
+ ->assertSet('series', []);
+});
+
+it('does not disclose another team application name for a sentinel-reported uuid', function () {
+ $server = bootEnabledGlobalServer();
+
+ $otherTeam = Team::factory()->create();
+ $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
+ $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
+ $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
+ $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id, 'network' => 'other-team-test']);
+
+ $otherTeamApplication = Application::factory()->create([
+ 'name' => 'Secret Other Team App',
+ 'environment_id' => $otherEnvironment->id,
+ 'destination_id' => $otherDestination->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+
+ $fake = new FakeGlobalAnalyticsTrafficClient($server);
+ $fake->responses = fakeGlobalAnalyticsResponses([$otherTeamApplication->uuid]);
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertDontSee('Secret Other Team App')
+ ->assertSee($otherTeamApplication->uuid);
+});
+
+it('scopes the view to a single application and hides the leaderboard when filtered by app', function () {
+ $server = bootEnabledGlobalServer();
+
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $destination = StandaloneDocker::where('server_id', $server->id)->first()
+ ?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
+
+ $application = Application::factory()->create([
+ 'name' => 'Filtered App',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+
+ $fake = new FakeGlobalAnalyticsTrafficClient($server);
+ $fake->responses = fakeGlobalAnalyticsResponses([$application->uuid]);
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->set('appUuid', $application->uuid)
+ ->assertOk()
+ ->assertSee('1,000')
+ ->assertSee('Top paths')
+ ->assertDontSee('Top applications');
+});
+
+it('still dispatches the chart refresh when every server fails so a stale chart clears', function () {
+ $server = bootEnabledGlobalServer();
+
+ // Server unreachable: the overview fetch throws, so no overviews are collected and
+ // loadData short-circuits via resetData(). The chart lives under wire:ignore, so it
+ // must still receive a refresh event to flip to its no-data state instead of keeping
+ // whatever it last plotted.
+ $fake = new class($server) extends SentinelTrafficClient
+ {
+ protected function raw(string $url): string
+ {
+ if (str_contains($url, '/traffic/overview')) {
+ throw new RuntimeException('server unreachable');
+ }
+
+ return '{}';
+ }
+ };
+ app()->bind(SentinelTrafficClient::class, fn () => $fake);
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSet('overview', null)
+ ->assertDispatched('refreshChartData-global-analytics-status');
+});
+
+it('shows the not-enabled empty state when no server has traffic analytics on', function () {
+ $server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSee('Traffic analytics is not enabled')
+ ->assertDontSee('Unique visitors');
+});
diff --git a/tests/Feature/TrafficAnalytics/LiveRefreshTest.php b/tests/Feature/TrafficAnalytics/LiveRefreshTest.php
new file mode 100644
index 0000000000..4ec260dee5
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/LiveRefreshTest.php
@@ -0,0 +1,101 @@
+ 1000,
+ 'bytes_in' => 5000,
+ 'bytes_out' => 25000,
+ 'status' => ['s2xx' => 900, 's3xx' => 50, 's4xx' => 40, 's5xx' => 10],
+ 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
+ 'unique_visitors' => 320,
+ ]);
+ }
+
+ return '[]';
+ }
+}
+
+function bootLiveServer(): Server
+{
+ Server::flushIdentityMap();
+ $team = Team::factory()->create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+ test()->actingAs($user);
+ session(['currentTeam' => $team]);
+
+ $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'private_key_id' => $privateKey->id,
+ ]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+
+ app()->bind(SentinelTrafficClient::class, fn () => new FakeLiveTrafficClient($server));
+
+ return $server;
+}
+
+it('is paused by default and does not poll until Live Refresh is turned on', function () {
+ $server = bootLiveServer();
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSet('live', false)
+ ->assertDontSeeHtml('wire:poll.60s')
+ ->assertSee('Live Refresh');
+});
+
+it('starts polling when live is toggled on at the 24h range', function () {
+ $server = bootLiveServer();
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertDontSeeHtml('wire:poll.60s')
+ ->call('toggleLive')
+ ->assertSet('live', true)
+ ->assertSeeHtml('wire:poll.60s')
+ ->assertSee('Live Refresh');
+});
+
+it('hides the Live Refresh control and stops polling for the 7d and 30d ranges', function () {
+ $server = bootLiveServer();
+
+ $component = loadLazy(Livewire::test(Analytics::class))
+ ->call('setRange', '7d')
+ ->assertDontSeeHtml('wire:poll.60s')
+ ->assertDontSee('Live Refresh');
+
+ expect($component->instance()->isLivePollable())->toBeFalse();
+
+ $component->call('setRange', '30d')
+ ->assertDontSeeHtml('wire:poll.60s')
+ ->assertDontSee('Live Refresh');
+});
+
+it('re-arms polling when returning to the 24h range after live was turned on', function () {
+ $server = bootLiveServer();
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->call('toggleLive') // arm live at 24h
+ ->assertSeeHtml('wire:poll.60s')
+ ->call('setRange', '7d')
+ ->assertDontSeeHtml('wire:poll.60s')
+ ->call('setRange', '24h')
+ ->assertSeeHtml('wire:poll.60s');
+});
diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php
new file mode 100644
index 0000000000..1eb0329c99
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/SentinelTrafficCacheTest.php
@@ -0,0 +1,31 @@
+calls++;
+
+ return '{"requests":0}';
+ }
+}
+
+it('caches identical requests within the TTL', function () {
+ Cache::flush();
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $client = new CountingTrafficClient($server);
+ $client->overview('app', 'a', 'b');
+ $client->overview('app', 'a', 'b');
+ expect($client->calls)->toBe(1);
+});
diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php
new file mode 100644
index 0000000000..05bfbcddf2
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php
@@ -0,0 +1,304 @@
+team = Team::factory()->create();
+});
+
+class FakeTrafficClient extends SentinelTrafficClient
+{
+ public array $captured = [];
+
+ public string $response = '{}';
+
+ protected function raw(string $url): string
+ {
+ $this->captured[] = $url;
+
+ return $this->response;
+ }
+}
+
+it('builds per-app overview url and parses response', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+ $client->response = json_encode([
+ 'requests' => 3, 'bytes_in' => 1, 'bytes_out' => 2,
+ 'status' => ['s2xx' => 3, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0],
+ 'latency' => ['p50' => 1, 'p95' => 2, 'p99' => 3], 'unique_visitors' => 2,
+ ]);
+
+ $dto = $client->overview('app-uuid', '2026-08-01T00:00:00Z', '2026-08-02T00:00:00Z');
+ expect($dto)->toBeInstanceOf(TrafficOverviewData::class)->and($dto->requests)->toBe(3);
+ expect($client->captured[0])->toContain('/api/app/app-uuid/traffic/overview')
+ ->toContain('from=2026-08-01T00:00:00Z')->toContain('to=2026-08-02T00:00:00Z');
+});
+
+it('builds server-wide overview url when appKey is null', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+ $client->response = json_encode(['requests' => 0]);
+ $client->overview(null, 'a', 'b');
+ expect($client->captured[0])->toContain('/api/traffic/overview')->not->toContain('/app/');
+});
+
+it('rejects a malicious app key that would break out of the shell quoting', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+
+ expect(fn () => $client->overview("x'; touch /tmp/pwned; '", 'a', 'b'))
+ ->toThrow(InvalidArgumentException::class);
+ expect(fn () => $client->breakdown('foo bar', 'country', 'a', 'b'))
+ ->toThrow(InvalidArgumentException::class);
+ expect($client->captured)->toBeEmpty();
+});
+
+it('rejects a dimension outside the known fixed set', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+
+ expect(fn () => $client->breakdown(null, "status'; touch /tmp/pwned; '", 'a', 'b'))
+ ->toThrow(InvalidArgumentException::class);
+ expect($client->captured)->toBeEmpty();
+});
+
+it('accepts a CUID2-like app key and builds the url', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+ $client->response = json_encode(['requests' => 0]);
+
+ $client->overview('cm2abc123xyz456uuid', 'a', 'b');
+
+ expect($client->captured[0])->toContain('/api/app/cm2abc123xyz456uuid/traffic/overview');
+});
+
+it('accepts a hostname-shaped app key and builds the url', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+ $client->response = json_encode(['requests' => 0]);
+
+ $client->overview('app.example.com', 'a', 'b');
+
+ expect($client->captured[0])->toContain('/api/app/app.example.com/traffic/overview');
+});
+
+it('accepts every known dimension and rejects unknown ones', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+ $client->response = json_encode([]);
+
+ foreach (['status', 'method', 'country', 'referer', 'browser', 'os', 'device', 'protocol', 'scheme', 'tls', 'cache', 'bot'] as $dimension) {
+ $client->breakdown(null, $dimension, 'a', 'b');
+ }
+ expect($client->captured)->toHaveCount(12);
+
+ expect(fn () => $client->breakdown(null, 'not-a-real-dimension', 'a', 'b'))
+ ->toThrow(InvalidArgumentException::class);
+});
+
+it('builds the server-wide series url with the range knob', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+ $client->response = json_encode([
+ ['bucket' => 1_700_000_000_000, 's2xx' => 5, 's3xx' => 1, 's4xx' => 0, 's5xx' => 0],
+ ]);
+
+ $rows = $client->series(null, '7d');
+
+ expect($rows)->toHaveCount(1)
+ ->and($rows->first()->bucket)->toBe(1_700_000_000_000)
+ ->and($rows->first()->s2xx)->toBe(5);
+ expect($client->captured[0])->toContain('/api/traffic/series')
+ ->toContain('range=7d')->not->toContain('/app/');
+});
+
+it('builds the per-app series url and defaults an unknown range to 24h', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+ $client->response = json_encode([]);
+
+ $client->series('cm2abc123xyz456uuid', 'bogus');
+
+ expect($client->captured[0])->toContain('/api/app/cm2abc123xyz456uuid/traffic/series')
+ ->toContain('range=24h');
+});
+
+it('returns an empty series when the endpoint is absent (older Sentinel 404)', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+
+ // Empty body / unparseable / empty array all mean "no series" β donut fallback.
+ foreach (['', 'Not Found', '{}', '[]'] as $body) {
+ $client->response = $body;
+ expect($client->series(null, '24h'))->toBeEmpty();
+ }
+});
+
+it('rejects a malicious app key for the series endpoint', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new FakeTrafficClient($server);
+
+ expect(fn () => $client->series("x'; rm -rf /; '", '24h'))
+ ->toThrow(InvalidArgumentException::class);
+ expect($client->captured)->toBeEmpty();
+});
+
+it('serves every endpoint from one aggregate dashboard fetch when Sentinel exposes it', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ $bundle = json_encode([
+ 'overview' => ['requests' => 7],
+ 'paths' => [['path' => '/', 'requests' => 7]],
+ 'breakdowns' => ['country' => [['value' => 'US', 'requests' => 7]], 'browser' => []],
+ 'series' => [],
+ 'attribution' => 'MaxMind',
+ 'apps' => [
+ ['uuid' => 'app-a', 'overview' => ['requests' => 4]],
+ ['uuid' => 'app-b', 'overview' => ['requests' => 3]],
+ ],
+ ]);
+
+ // Only the /traffic/dashboard fetch is allowed; any individual/batch fetch means the
+ // bundle wasn't decomposed into the per-endpoint cache.
+ $client = new class($server, $bundle) extends SentinelTrafficClient
+ {
+ public function __construct($server, private string $bundle)
+ {
+ parent::__construct($server);
+ }
+
+ protected function remoteFetch(string $url): string
+ {
+ if (str_contains($url, '/traffic/dashboard')) {
+ return $this->bundle;
+ }
+ throw new RuntimeException("unexpected individual fetch: {$url}");
+ }
+
+ protected function batchRemoteFetch(array $urls): string
+ {
+ throw new RuntimeException('batch fallback should not run when the dashboard is available');
+ }
+ };
+
+ $apps = $client->prefetchServerWide(null, 'F', 'T', ['country', 'browser'], '24h');
+ expect($apps)->toBe(['app-a', 'app-b']);
+
+ // All served from the seeded cache β remoteFetch throws for anything but the dashboard.
+ expect($client->overview(null, 'F', 'T')->requests)->toBe(7)
+ ->and($client->overview('app-a', 'F', 'T')->requests)->toBe(4)
+ ->and($client->attribution())->toBe('MaxMind');
+ $client->paths(null, 'F', 'T');
+ $client->breakdown(null, 'country', 'F', 'T');
+});
+
+it('warms every server-wide endpoint in a single batched exec and per-call methods hit cache', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ // Batches responses; individual remoteFetch() must never run once the batch has warmed
+ // the cache, proving the round-trips collapsed into one exec.
+ $client = new class($server) extends SentinelTrafficClient
+ {
+ public array $batchedCalls = [];
+
+ protected function batchRemoteFetch(array $urls): string
+ {
+ $this->batchedCalls[] = $urls;
+
+ // One framed body per url, matched by endpoint so ordering stays irrelevant.
+ $bodies = array_map(fn ($url) => match (true) {
+ str_contains($url, '/traffic/apps') => json_encode(['app-a', 'app-b']),
+ str_contains($url, '/attribution') => '{"attribution":"demo"}',
+ str_contains($url, '/overview') => '{"requests":1}',
+ default => '[]', // paths, series, breakdowns
+ }, $urls);
+
+ return implode("\x1e", $bodies)."\x1e";
+ }
+
+ protected function remoteFetch(string $url): string
+ {
+ throw new RuntimeException("individual fetch should not run for: {$url}");
+ }
+ };
+
+ $apps = $client->prefetchServerWide(null, 'F', 'T', ['country', 'browser'], '24h');
+
+ expect($client->batchedCalls)->toHaveCount(1)
+ ->and($apps)->toBe(['app-a', 'app-b']);
+
+ // These now read from the warmed cache; remoteFetch() would throw if they didn't.
+ expect($client->overview(null, 'F', 'T')->requests)->toBe(1);
+ $client->breakdown(null, 'country', 'F', 'T');
+ $client->series(null, '24h');
+ $client->attribution();
+});
+
+it('probes the absent dashboard route only once per cache window, then reuses the batch fallback', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ // Older Sentinel: the dashboard route 404s (unparseable body), so raw() throws and the
+ // client falls back to the batch. The absence must be remembered so a second prefetch in
+ // the same window doesn't re-probe the dashboard over SSH.
+ $client = new class($server) extends SentinelTrafficClient
+ {
+ public int $dashboardProbes = 0;
+
+ public int $batchCalls = 0;
+
+ protected function remoteFetch(string $url): string
+ {
+ if (str_contains($url, '/traffic/dashboard')) {
+ $this->dashboardProbes++;
+
+ return 'Not Found';
+ }
+ throw new RuntimeException("unexpected individual fetch: {$url}");
+ }
+
+ protected function batchRemoteFetch(array $urls): string
+ {
+ $this->batchCalls++;
+ $bodies = array_map(fn ($url) => match (true) {
+ str_contains($url, '/traffic/apps') => json_encode(['app-a']),
+ str_contains($url, '/attribution') => '{"attribution":"demo"}',
+ str_contains($url, '/overview') => '{"requests":1}',
+ default => '[]',
+ }, $urls);
+
+ return implode("\x1e", $bodies)."\x1e";
+ }
+ };
+
+ $client->prefetchServerWide(null, 'F', 'T', ['country'], '24h');
+ $client->prefetchServerWide(null, 'F', 'T', ['country'], '24h');
+
+ expect($client->dashboardProbes)->toBe(1)
+ ->and($client->batchCalls)->toBe(1);
+});
+
+it('double-quotes the url in the remote curl command so & is not a shell background operator', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $client = new class($server) extends SentinelTrafficClient
+ {
+ public function exposeCommand(string $token, string $url): string
+ {
+ return $this->buildFetchCommand($token, $url);
+ }
+ };
+
+ // A real overview/paths/breakdown URL carries both from and to, joined by `&`.
+ $url = 'http://localhost:8888/api/traffic/overview?from=2026-08-01T00:00:00Z&to=2026-08-02T00:00:00Z';
+ $command = $client->exposeCommand('tok-123', $url);
+
+ // The URL must be wrapped in double quotes inside the inner `sh -c`, otherwise the
+ // container shell backgrounds curl at the `&` and only `from=...` reaches Sentinel.
+ expect($command)->toContain('"'.$url.'"');
+});
diff --git a/tests/Feature/TrafficAnalytics/ServerAnalyticsPageTest.php b/tests/Feature/TrafficAnalytics/ServerAnalyticsPageTest.php
new file mode 100644
index 0000000000..cdf0eb4ab4
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/ServerAnalyticsPageTest.php
@@ -0,0 +1,174 @@
+ 0]);
+ $this->user = User::factory()->create();
+ $this->team = $this->user->teams()->first();
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+it('registers a server analytics page in the server sidebar', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ expect(route('server.analytics', ['server_uuid' => $server->uuid]))
+ ->toEndWith("/server/{$server->uuid}/analytics");
+
+ $sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
+
+ expect($sidebar)
+ ->toContain("'label' => 'Analytics'")
+ ->toContain("'route' => 'server.analytics'");
+});
+
+it('prevents access to another teams server analytics page', function () {
+ $otherUser = User::factory()->create();
+ $otherServer = Server::factory()->create(['team_id' => $otherUser->teams()->first()->id]);
+
+ expect(fn () => Livewire::test(Show::class, ['server_uuid' => $otherServer->uuid]))
+ ->toThrow(ModelNotFoundException::class);
+});
+
+it('moves traffic analytics configuration out of sentinel and onto analytics', function () {
+ $analyticsView = file_get_contents(resource_path('views/livewire/server/traffic-analytics-settings.blade.php'));
+ $sentinelView = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
+
+ expect($analyticsView)
+ ->toContain('id="server-traffic-analytics-settings-section"')
+ ->toContain('title="Traffic analytics"')
+ ->not->toContain('title="Traffic analytics settings"')
+ ->toContain('id="trafficTopn"')
+ ->toContain('id="trafficSampleThreshold"')
+ ->toContain('id="trafficRetention1hDays"')
+ ->toContain('id="trafficRetention1dDays"')
+ ->toContain('id="isGeoipEnabled"')
+ ->toContain('id="geoipRefreshDays"')
+ ->toContain('id="geoipMaxmindLicenseKey"')
+ ->and($sentinelView)
+ ->not->toContain('id="server-sentinel-traffic-analytics-section"')
+ ->not->toContain('id="trafficTopn"');
+});
+
+it('matches the server metrics empty state when traffic analytics is disabled', function () {
+ $view = file_get_contents(resource_path('views/livewire/server/traffic-analytics-settings.blade.php'));
+ $disabledState = str($view)
+ ->after('@else')
+ ->before('@endif')
+ ->toString();
+
+ expect($disabledState)
+ ->toContain('title="Traffic analytics is disabled"')
+ ->toContain('')
+ ->toContain('isHighlightedButton')
+ ->toContain('buttonTitle="Enable traffic analytics"');
+});
+
+it('renders traffic analytics settings above the server analytics dashboard', function () {
+ $view = file_get_contents(resource_path('views/livewire/server/analytics/show.blade.php'));
+
+ expect(strpos($view, 'toBeLessThan(strpos($view, 'toContain(':lazy="$server->isTrafficAnalyticsEnabled()"');
+});
+
+it('matches other server pages without a visible page title', function () {
+ $view = file_get_contents(resource_path('views/livewire/server/analytics/show.blade.php'));
+
+ expect($view)
+ ->not->toContain('>Analytics')
+ ->toContain('create(['team_id' => $this->team->id]);
+ $otherServer = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+ $otherServer->settings->is_traffic_analytics_enabled = false;
+ $otherServer->settings->save();
+
+ Livewire::test(Analytics::class, ['scopedServerUuid' => $server->uuid])
+ ->assertSet('scopedServerUuid', $server->uuid)
+ ->assertDontSee($otherServer->name);
+});
+
+it('does not duplicate the disabled state on a scoped server analytics dashboard', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ Livewire::test(Analytics::class, ['scopedServerUuid' => $server->uuid])
+ ->assertDontSee('Traffic analytics is not enabled');
+});
+
+it('removes stale analytics content when traffic analytics is disabled', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+
+ $component = Livewire::test(Analytics::class, ['scopedServerUuid' => $server->uuid]);
+
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ $component
+ ->dispatch('trafficAnalyticsStateChanged')
+ ->assertSet('servers', fn ($servers) => $servers->isEmpty())
+ ->assertDontSee('No analytics data yet');
+});
+
+it('does not render a skeleton placeholder for a disabled scoped server', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ Livewire::test(Analytics::class, ['scopedServerUuid' => $server->uuid, 'lazy' => true])
+ ->assertDontSee('analytics-overview-section')
+ ->assertDontSee('analytics-requests-section');
+});
+
+it('saves traffic analytics settings from the server analytics page', function () {
+ Queue::fake();
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
+ ->set('trafficTopn', 100)
+ ->set('trafficSampleThreshold', 500)
+ ->set('trafficRetention1hDays', 14)
+ ->set('trafficRetention1dDays', 180)
+ ->set('isGeoipEnabled', false)
+ ->set('geoipRefreshDays', 7)
+ ->call('saveTrafficAnalyticsSettings')
+ ->assertHasNoErrors();
+
+ $settings = $server->settings->fresh();
+
+ expect($settings->traffic_topn)->toBe(100)
+ ->and($settings->traffic_sample_threshold)->toBe(500)
+ ->and($settings->traffic_retention_1h_days)->toBe(14)
+ ->and($settings->traffic_retention_1d_days)->toBe(180)
+ ->and($settings->is_geoip_enabled)->toBeFalse()
+ ->and($settings->geoip_refresh_days)->toBe(7);
+});
diff --git a/tests/Feature/TrafficAnalytics/ServerSettingTrafficTest.php b/tests/Feature/TrafficAnalytics/ServerSettingTrafficTest.php
new file mode 100644
index 0000000000..3f7d7d0b7a
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/ServerSettingTrafficTest.php
@@ -0,0 +1,75 @@
+create();
+ $this->team = $user->teams()->first();
+});
+
+it('defaults traffic analytics to disabled for a normal server and exposes a server helper', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ expect($server->settings->is_traffic_analytics_enabled)->toBeFalse();
+ expect($server->isTrafficAnalyticsEnabled())->toBeFalse();
+
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+ expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue();
+});
+
+it('defaults traffic analytics to disabled for a swarm server', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $setting = ServerSetting::create([
+ 'server_id' => $server->id,
+ 'is_swarm_manager' => true,
+ ]);
+
+ expect($setting->is_traffic_analytics_enabled)->toBeFalse();
+});
+
+it('defaults traffic analytics to disabled for a build server', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $setting = ServerSetting::create([
+ 'server_id' => $server->id,
+ 'is_build_server' => true,
+ ]);
+
+ expect($setting->is_traffic_analytics_enabled)->toBeFalse();
+});
+
+it('respects an explicit enabled traffic analytics value on creation', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $setting = ServerSetting::create([
+ 'server_id' => $server->id,
+ 'is_traffic_analytics_enabled' => true,
+ ]);
+
+ expect($setting->is_traffic_analytics_enabled)->toBeTrue();
+});
+
+it('defaults traffic collection and geoip settings to sentinel values', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ expect($server->settings->traffic_topn)->toBe(50)
+ ->and($server->settings->traffic_sample_threshold)->toBe(0)
+ ->and($server->settings->traffic_retention_1h_days)->toBe(30)
+ ->and($server->settings->traffic_retention_1d_days)->toBe(395)
+ ->and($server->settings->is_geoip_enabled)->toBeTrue()
+ ->and($server->settings->geoip_refresh_days)->toBe(30);
+});
+
+it('encrypts the maxmind license key and hides it from array output', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->geoip_maxmind_license_key = 'secret-key';
+ $server->settings->save();
+
+ expect($server->settings->fresh()->geoip_maxmind_license_key)->toBe('secret-key');
+ expect(array_key_exists('geoip_maxmind_license_key', $server->settings->fresh()->toArray()))->toBeFalse();
+});
diff --git a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php
new file mode 100644
index 0000000000..b8856f4c67
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php
@@ -0,0 +1,87 @@
+create();
+ $this->team = $user->teams()->first();
+});
+
+it('produces no traffic env when disabled', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+ expect(StartSentinel::sentinelTrafficEnvironment($server->fresh()))->toBe([]);
+});
+
+it('produces traffic + geoip env when enabled', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->geoip_maxmind_license_key = 'lic';
+ $server->settings->save();
+
+ $env = StartSentinel::sentinelTrafficEnvironment($server->fresh());
+ expect($env['TRAFFIC_ENABLED'])->toBe('true');
+ expect($env['TRAFFIC_PROXY_TYPE'])->toBe('auto');
+ expect($env['TRAFFIC_TOPN'])->toBe('50');
+ expect($env['TRAFFIC_SAMPLE_THRESHOLD'])->toBe('0');
+ expect($env['TRAFFIC_RETENTION_1H_DAYS'])->toBe('30');
+ expect($env['TRAFFIC_RETENTION_1D_DAYS'])->toBe('395');
+ expect($env['GEOIP_ENABLED'])->toBe('true');
+ expect($env['GEOIP_REFRESH_DAYS'])->toBe('30');
+ expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('lic');
+ expect($env)->toHaveKey('TRAFFIC_ACCESS_LOG_PATH');
+});
+
+it('uses the dev proxy volume for traffic logs locally', function () {
+ config()->set('app.env', 'local');
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+
+ expect(StartSentinel::trafficLogDirectory($server->fresh()))
+ ->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy');
+ expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_ACCESS_LOG_PATH'])
+ ->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/access.log');
+});
+
+it('passes custom traffic settings as sentinel env', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->traffic_topn = 100;
+ $server->settings->traffic_sample_threshold = 500;
+ $server->settings->traffic_retention_1h_days = 14;
+ $server->settings->traffic_retention_1d_days = 180;
+ $server->settings->is_geoip_enabled = false;
+ $server->settings->geoip_refresh_days = 7;
+ // A key may still be stored while GeoIP is off; it must not reach Sentinel.
+ $server->settings->geoip_maxmind_license_key = 'secret-maxmind-key';
+ $server->settings->save();
+
+ $env = StartSentinel::sentinelTrafficEnvironment($server->fresh());
+ expect($env['TRAFFIC_TOPN'])->toBe('100');
+ expect($env['TRAFFIC_SAMPLE_THRESHOLD'])->toBe('500');
+ expect($env['TRAFFIC_RETENTION_1H_DAYS'])->toBe('14');
+ expect($env['TRAFFIC_RETENTION_1D_DAYS'])->toBe('180');
+ expect($env['GEOIP_ENABLED'])->toBe('false');
+ expect($env['GEOIP_REFRESH_DAYS'])->toBe('7');
+ expect($env)->not->toHaveKey('GEOIP_MAXMIND_LICENSE_KEY');
+});
+
+it('injects the maxmind license key only when geoip is enabled', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->is_geoip_enabled = true;
+ $server->settings->geoip_maxmind_license_key = 'secret-maxmind-key';
+ $server->settings->save();
+
+ $env = StartSentinel::sentinelTrafficEnvironment($server->fresh());
+ expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('secret-maxmind-key');
+});
diff --git a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php
new file mode 100644
index 0000000000..63864aada1
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php
@@ -0,0 +1,131 @@
+ 0]);
+ $this->user = User::factory()->create();
+ $this->team = $this->user->teams()->first();
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+it('toggles traffic analytics via the sentinel settings component', function () {
+ ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->once()->andReturnUsing(function ($server, $enable) {
+ $server->settings->is_traffic_analytics_enabled = $enable;
+ $server->settings->save();
+ });
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ // New servers default analytics on; start from the disabled state to exercise enabling.
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
+
+ Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
+ ->call('toggleTrafficAnalytics')
+ ->assertDispatchedTo(Analytics::class, 'trafficAnalyticsStateChanged')
+ ->assertHasNoErrors();
+
+ expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue();
+});
+
+it('warns about the application interruption before enabling traffic analytics', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
+ ->assertSee('Enable traffic analytics?')
+ ->assertDontSeeHtml('wire:confirm')
+ ->assertSeeHtml('wire:loading.flex')
+ ->assertSeeHtml('wire:target="toggleTrafficAnalytics"')
+ ->assertSee('Restarting Sentinel and proxy...')
+ ->assertSee('Enabling traffic analytics will restart Sentinel and the proxy. Your applications will experience a brief interruption.');
+});
+
+it('allows the analytics toggle modal to update after the state changes', function () {
+ $view = file_get_contents(resource_path('views/livewire/server/traffic-analytics-settings.blade.php'));
+
+ expect($view)->toContain(':ignoreWire="false"');
+});
+
+it('does not enable traffic analytics on a swarm server', function () {
+ ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->never();
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_swarm_manager = true;
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
+
+ Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
+ ->call('toggleTrafficAnalytics')
+ ->assertHasNoErrors();
+
+ expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
+});
+
+it('saves traffic analytics settings from the sentinel form', function () {
+ Queue::fake();
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+
+ Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
+ ->set('trafficTopn', 100)
+ ->set('trafficSampleThreshold', 500)
+ ->set('trafficRetention1hDays', 14)
+ ->set('trafficRetention1dDays', 180)
+ ->set('isGeoipEnabled', false)
+ ->set('geoipRefreshDays', 7)
+ ->call('saveTrafficAnalyticsSettings')
+ ->assertHasNoErrors();
+
+ $settings = $server->settings->fresh();
+ expect($settings->traffic_topn)->toBe(100)
+ ->and($settings->traffic_sample_threshold)->toBe(500)
+ ->and($settings->traffic_retention_1h_days)->toBe(14)
+ ->and($settings->traffic_retention_1d_days)->toBe(180)
+ ->and($settings->is_geoip_enabled)->toBeFalse()
+ ->and($settings->geoip_refresh_days)->toBe(7);
+});
+
+it('rejects a zero top-n cap', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
+ ->set('trafficTopn', 0)
+ ->call('saveTrafficAnalyticsSettings')
+ ->assertHasErrors(['trafficTopn']);
+});
+
+it('does not enable traffic analytics on a build server', function () {
+ ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->never();
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $server->settings->is_build_server = true;
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
+
+ Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
+ ->call('toggleTrafficAnalytics')
+ ->assertHasNoErrors();
+
+ expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
+});
diff --git a/tests/Feature/TrafficAnalytics/TraefikAccessLogTest.php b/tests/Feature/TrafficAnalytics/TraefikAccessLogTest.php
new file mode 100644
index 0000000000..bddc577095
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/TraefikAccessLogTest.php
@@ -0,0 +1,20 @@
+toBe([]);
+});
+
+it('returns JSON access log + Cloudflare header capture flags when enabled', function () {
+ $cmds = traefikAccessLogCommands(true);
+ expect($cmds)->toContain('--accesslog=true')
+ ->toContain('--accesslog.filepath=/traefik/access.log')
+ ->toContain('--accesslog.format=json')
+ ->toContain('--accesslog.fields.headers.names.Cf-Connecting-Ip=keep')
+ ->toContain('--accesslog.fields.headers.names.Cf-Ipcountry=keep')
+ ->toContain('--accesslog.fields.headers.names.Cf-Cache-Status=keep')
+ ->toContain('--accesslog.fields.headers.names.Cf-Verified-Bot=keep')
+ ->toContain('--accesslog.fields.headers.names.Cf-Ray=keep')
+ ->toContain('--accesslog.fields.headers.names.X-Forwarded-For=keep')
+ ->toContain('--accesslog.fields.headers.names.User-Agent=keep')
+ ->toContain('--accesslog.fields.headers.names.Referer=keep');
+});
diff --git a/tests/Feature/TrafficAnalytics/TraefikLogrotateSidecarTest.php b/tests/Feature/TrafficAnalytics/TraefikLogrotateSidecarTest.php
new file mode 100644
index 0000000000..e629a284bc
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/TraefikLogrotateSidecarTest.php
@@ -0,0 +1,60 @@
+create();
+ $this->team = $user->teams()->first();
+
+ $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+});
+
+it('does not add a traefik-logrotate sidecar when traffic analytics is disabled', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
+ $server->proxy->set('type', 'TRAEFIK');
+ $server->save();
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ $yaml = generateDefaultProxyConfiguration($server->fresh());
+
+ expect($yaml)->not->toContain('traefik-logrotate');
+
+ $config = Yaml::parse($yaml);
+ expect($config['services'])->not->toHaveKey('traefik-logrotate');
+});
+
+it('adds a traefik-logrotate sidecar with copytruncate and the proxy mount when enabled', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
+ $server->proxy->set('type', 'TRAEFIK');
+ $server->save();
+ $server->settings->is_traffic_analytics_enabled = true;
+ $server->settings->save();
+
+ $server = $server->fresh();
+ $yaml = generateDefaultProxyConfiguration($server);
+
+ expect($yaml)->toContain('traefik-logrotate')
+ ->toContain('copytruncate');
+
+ $config = Yaml::parse($yaml);
+ $sidecar = $config['services']['traefik-logrotate'];
+
+ expect($sidecar['image'])->toBe('alpine:3.20');
+ expect($sidecar['volumes'])->toContain($server->proxyPath().':/traefik');
+ expect($sidecar['labels'])->toContain('coolify.managed=true');
+ expect($sidecar['entrypoint'])->toContain('copytruncate');
+ expect($sidecar['entrypoint'])->toContain('logrotate');
+});
diff --git a/tests/Feature/TrafficAnalytics/TrafficAnalyticsAggregatorTest.php b/tests/Feature/TrafficAnalytics/TrafficAnalyticsAggregatorTest.php
new file mode 100644
index 0000000000..ef9f7325f6
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/TrafficAnalyticsAggregatorTest.php
@@ -0,0 +1,26 @@
+requests)->toBe(30);
+ expect($o->bytesOut)->toBe(6);
+ expect($o->s2xx)->toBe(26);
+ expect($o->uniqueVisitors)->toBe(10); // summed
+ expect($o->latencyP95)->toBe(50.0); // max across servers
+ expect($result['latencyApproximate'])->toBeTrue();
+ expect($result['uniquesApproximate'])->toBeTrue();
+});
+
+it('returns a zeroed, non-approximate result for a single server', function () {
+ $a = new TrafficOverviewData(10, 1, 2, 8, 1, 1, 0, 5.0, 20.0, 30.0, 4);
+ $result = TrafficAnalyticsAggregator::sumOverviews([$a]);
+ expect($result['latencyApproximate'])->toBeFalse();
+ expect($result['uniquesApproximate'])->toBeFalse();
+});
diff --git a/tests/Feature/TrafficAnalytics/TrafficDataTest.php b/tests/Feature/TrafficAnalytics/TrafficDataTest.php
new file mode 100644
index 0000000000..b75fd0b550
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/TrafficDataTest.php
@@ -0,0 +1,38 @@
+ 10, 'bytes_in' => 100, 'bytes_out' => 200,
+ 'status' => ['s2xx' => 8, 's3xx' => 1, 's4xx' => 1, 's5xx' => 0],
+ 'latency' => ['p50' => 12.5, 'p95' => 40.0, 'p99' => 90.0],
+ 'unique_visitors' => 5,
+ ];
+ $dto = TrafficOverviewData::fromSentinel($json);
+ expect($dto->requests)->toBe(10);
+ expect($dto->s4xx)->toBe(1);
+ expect($dto->latencyP95)->toBe(40.0);
+ expect($dto->uniqueVisitors)->toBe(5);
+});
+
+it('produces a zeroed overview', function () {
+ expect(TrafficOverviewData::zero()->requests)->toBe(0);
+});
+
+it('maps per-path error counters from sentinel', function () {
+ $dto = TrafficPathData::fromSentinel([
+ 'path' => '/api/checkout',
+ 'app' => 'app-1',
+ 'requests' => 20,
+ 'bytes_out' => 1000,
+ 's4xx' => 3,
+ 's5xx' => 2,
+ 'p50' => 10,
+ 'p95' => 30,
+ ]);
+
+ expect($dto->s4xx)->toBe(3)
+ ->and($dto->s5xx)->toBe(2);
+});
diff --git a/tests/Feature/TrafficAnalytics/TrafficNudgeTest.php b/tests/Feature/TrafficAnalytics/TrafficNudgeTest.php
new file mode 100644
index 0000000000..eb2eb83de9
--- /dev/null
+++ b/tests/Feature/TrafficAnalytics/TrafficNudgeTest.php
@@ -0,0 +1,64 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+ $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+});
+
+function disabledServer(): Server
+{
+ $server = Server::factory()->create([
+ 'team_id' => test()->team->id,
+ 'private_key_id' => test()->privateKey->id,
+ ]);
+ $server->settings->is_traffic_analytics_enabled = false;
+ $server->settings->save();
+
+ return $server;
+}
+
+it('does not show a traffic nudge on the dashboard', function () {
+ disabledServer();
+
+ loadLazy(Livewire::test(DashboardTrafficAnalytics::class))
+ ->assertOk()
+ ->assertDontSee('can start collecting traffic analytics');
+});
+
+it('shows the analytics-page nudge when an eligible server has analytics disabled', function () {
+ disabledServer();
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertSee('can start collecting traffic analytics');
+});
+
+it('does not count swarm or build servers in the analytics-page nudge', function () {
+ $swarm = disabledServer();
+ $swarm->settings->is_swarm_manager = true;
+ $swarm->settings->save();
+
+ $build = disabledServer();
+ $build->settings->is_build_server = true;
+ $build->settings->save();
+
+ loadLazy(Livewire::test(Analytics::class))
+ ->assertOk()
+ ->assertDontSee('can start collecting traffic analytics');
+});
diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php
index 36a04cecaa..c1312552a6 100644
--- a/tests/Feature/VolumeBackupTest.php
+++ b/tests/Feature/VolumeBackupTest.php
@@ -8,7 +8,7 @@ use App\Livewire\Project\Application\Backup\Create as CreateScheduledVolumeBacku
use App\Livewire\Project\Service\FileStorage;
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex;
-use App\Livewire\Project\Shared\Storages\Show;
+use App\Livewire\Project\Shared\Storages\All;
use App\Livewire\Project\Shared\Storages\VolumeBackups;
use App\Models\Application;
use App\Models\Environment;
@@ -484,11 +484,8 @@ it('shows the configure backup modal trigger inside the volume card instead of i
signInForVolumeBackups($this, $team);
[$application, $volume] = createVolumeBackupApplication($team);
- $component = Livewire::test(Show::class, [
- 'storage' => $volume,
- 'resource' => $application,
- ])
- ->set('isReadOnly', true)
+ $component = Livewire::test(All::class, ['resource' => $application])
+ ->set("forms.{$volume->id}.isReadOnly", true)
->assertSee('Backup')
->assertDontSee('Backups made while the application is writing');
@@ -498,6 +495,7 @@ it('shows the configure backup modal trigger inside the volume card instead of i
expect($html)
->toContain('Configure Volume Backup')
->toContain('data-table-row')
+ ->not->toContain('wire:submit="submit('.$volume->id.')"')
->toContain('Backup');
});
@@ -513,10 +511,10 @@ it('only shows the backup enabled badge for an enabled volume backup', function
'enabled' => false,
]);
- $component = Livewire::test(Show::class, [
- 'storage' => $volume,
- 'resource' => $application,
- ])->assertDontSee('table-badge-success', false);
+ $component = Livewire::test(All::class, ['resource' => $application])
+ ->assertDontSee('Volume backup is enabled');
+
+ expect($component->get("volumeBackupMeta.{$volume->id}.enabled"))->toBeFalse();
$backup->update(['enabled' => true]);
@@ -529,17 +527,10 @@ it('only shows the backup enabled badge for an enabled volume backup', function
$component
->dispatch('refreshVolumeBackups')
- ->assertSee('table-badge-success', false)
->assertSee('Volume backup is enabled')
->assertSee('href="'.$backupUrl.'"', false);
- Livewire::test(Show::class, [
- 'storage' => $volume,
- 'resource' => $application,
- 'isFirst' => false,
- ])
- ->assertSee('table-badge-success', false)
- ->assertSee('Volume backup is enabled');
+ expect($component->get("volumeBackupMeta.{$volume->id}.url"))->toBe($backupUrl);
});
it('links the backup enabled badge to a filtered backup list when the application has multiple schedules', function () {
@@ -565,11 +556,7 @@ it('links the backup enabled badge to a filtered backup list when the applicatio
'search' => $volume->name,
]);
- Livewire::test(Show::class, [
- 'storage' => $volume,
- 'resource' => $application,
- ])
- ->assertSee('table-badge-success', false)
+ Livewire::test(All::class, ['resource' => $application])
->assertSee('Volume backup is enabled')
->assertSee('href="'.$backupUrl.'"', false);
});
diff --git a/tests/Pest.php b/tests/Pest.php
index 25d9440108..d143e9c125 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -2,6 +2,7 @@
use App\Models\Server;
use Illuminate\Support\Once;
+use Livewire\Features\SupportTesting\Testable;
use Tests\TestCase;
/*
@@ -44,6 +45,25 @@ function remoteOutputSource(string $path): string
return $source;
}
+/**
+ * Trigger the deferred mount of a #[Lazy] Livewire component the way the browser would
+ * via its x-intersect `__lazyLoad(...)` call, so assertions can run against the real
+ * (post-mount) render instead of the placeholder.
+ */
+function loadLazy(Testable $component): Testable
+{
+ preg_match('/__lazyLoad\('([^&]+)'\)/', $component->html(), $matches);
+
+ if (empty($matches)) {
+ // No trigger means the component isn't lazy (or the placeholder markup changed).
+ // Fail loudly rather than silently asserting against the un-mounted placeholder,
+ // which would turn a lazy-load regression into a false-positive pass.
+ throw new RuntimeException('loadLazy: no __lazyLoad trigger found β component is not #[Lazy] or its placeholder markup changed.');
+ }
+
+ return $component->call('__lazyLoad', $matches[1]);
+}
+
/*
|--------------------------------------------------------------------------
| Test Hooks
diff --git a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php
new file mode 100644
index 0000000000..d8050c84d9
--- /dev/null
+++ b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php
@@ -0,0 +1,62 @@
+invoke(new InstallPrerequisites);
+
+ expect($commands)->toContain('command -v bash >/dev/null || apk add bash');
+});
+
+it('installs every Docker CLI plugin required on Alpine', function () {
+ $method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand');
+
+ $command = $method->invoke(new InstallDocker);
+
+ expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose');
+});
+
+it('uses OpenRC instead of systemd to restart Docker on Alpine', function () {
+ $method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands');
+
+ $action = new InstallDocker;
+ $commands = $method->invoke($action, true);
+
+ expect($commands)
+ ->toBe(['rc-update add docker default', 'rc-service docker restart'])
+ ->each->not->toContain('systemctl')
+ ->and($method->invoke($action, false))
+ ->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']);
+});
+
+it('parses Alpine package updates', function () {
+ $method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput');
+ $output = <<<'OUTPUT'
+docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
+libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0]
+OUTPUT;
+
+ $result = $method->invoke(new CheckUpdates, $output);
+
+ expect($result)->toBe([
+ 'total_updates' => 2,
+ 'updates' => [
+ [
+ 'package' => 'docker-cli-compose',
+ 'new_version' => '2.31.0-r5',
+ 'architecture' => 'x86_64',
+ 'current_version' => '2.31.0-r4',
+ ],
+ [
+ 'package' => 'libcrypto3',
+ 'new_version' => '3.3.4-r0',
+ 'architecture' => 'aarch64',
+ 'current_version' => '3.3.3-r0',
+ ],
+ ],
+ ]);
+});
diff --git a/tests/Unit/ApplicationDeploymentContainerNamingTest.php b/tests/Unit/ApplicationDeploymentContainerNamingTest.php
index b7b63ae544..385e547eb6 100644
--- a/tests/Unit/ApplicationDeploymentContainerNamingTest.php
+++ b/tests/Unit/ApplicationDeploymentContainerNamingTest.php
@@ -28,19 +28,11 @@ function applicationWithContainerNaming(string $customName = 'shadowuw'): Applic
}
it('uses the custom container name when consistent naming is enabled', function () {
- $application = applicationWithContainerNaming();
-
- [$job, $reflection] = containerNamingJob($application);
-
- expect($reflection->getMethod('resolveContainerName')->invoke($job))->toBe('shadowuw');
+ expect(generateApplicationContainerName(applicationWithContainerNaming()))->toBe('shadowuw');
});
it('adds the pull request suffix to a custom container name', function () {
- $application = applicationWithContainerNaming();
-
- [$job, $reflection] = containerNamingJob($application, 42);
-
- expect($reflection->getMethod('resolveContainerName')->invoke($job))->toBe('shadowuw-pr-42');
+ expect(generateApplicationContainerName(applicationWithContainerNaming(), 42))->toBe('shadowuw-pr-42');
});
it('includes old generated containers when cleaning up a consistent deployment', function () {
@@ -56,3 +48,38 @@ it('includes old generated containers when cleaning up a consistent deployment',
expect($reflection->getMethod('containerNamesToRemove')->invoke($job, $containers)->all())
->toBe(['application-uuid-192238854305', 'shadowuw']);
});
+
+it('ignores the custom container name when consistent naming is disabled', function () {
+ $application = applicationWithContainerNaming();
+ $application->settings->is_consistent_container_name_enabled = false;
+
+ expect(generateApplicationContainerName($application))->toStartWith('application-uuid-');
+});
+
+it('recognises generated container names in both timestamp formats', function () {
+ expect(isGeneratedContainerName('application-uuid-20260908T141530'))->toBeTrue()
+ ->and(isGeneratedContainerName('my-api-20260908T141530'))->toBeTrue()
+ ->and(isGeneratedContainerName('application-uuid-192238854305'))->toBeTrue()
+ ->and(isGeneratedContainerName('application-uuid'))->toBeFalse()
+ ->and(isGeneratedContainerName('application-uuid-pr-42'))->toBeFalse()
+ ->and(isGeneratedContainerName('my-api'))->toBeFalse();
+});
+
+function applicationWithContainerNamePrefix(string $prefix = 'my-api', bool $consistent = false): Application
+{
+ $application = new Application;
+ $application->forceFill(['uuid' => 'application-uuid']);
+ $application->setRelation('settings', new ApplicationSetting([
+ 'custom_container_name_prefix' => $prefix,
+ 'is_consistent_container_name_enabled' => $consistent,
+ ]));
+
+ return $application;
+}
+
+it('uses the container name prefix for generated container names only', function () {
+ expect(generateApplicationContainerName(applicationWithContainerNamePrefix()))->toMatch('/^my-api-\d{8}T\d{6}$/')
+ ->and(generateApplicationContainerName(applicationWithContainerNamePrefix('')))->toMatch('/^application-uuid-\d{8}T\d{6}$/')
+ ->and(generateApplicationContainerName(applicationWithContainerNamePrefix(consistent: true)))->toBe('application-uuid')
+ ->and(generateApplicationContainerName(applicationWithContainerNamePrefix(), 42))->toBe('application-uuid-pr-42');
+});
diff --git a/tests/Unit/ApplicationDeploymentRemoteSecretValueTest.php b/tests/Unit/ApplicationDeploymentRemoteSecretValueTest.php
new file mode 100644
index 0000000000..12329be7df
--- /dev/null
+++ b/tests/Unit/ApplicationDeploymentRemoteSecretValueTest.php
@@ -0,0 +1,14 @@
+newInstanceWithoutConstructor();
+ $method = new ReflectionMethod(ApplicationDeploymentJob::class, 'format_remote_secret_value');
+
+ expect($method->invoke($job, $value))->toBe($expected);
+})->with([
+ 'object containing a variable reference' => ['{"password":"$ecret"}', '\'{"password":"$ecret"}\''],
+ 'array containing a comment marker' => ['["value # not a comment"]', '\'["value # not a comment"]\''],
+ 'object containing an apostrophe' => ['{"password":"it\'s $ecret"}', '"{\\"password\\":\\"it\'s $$ecret\\"}"'],
+]);
diff --git a/tests/Unit/CountryHelpersTest.php b/tests/Unit/CountryHelpersTest.php
new file mode 100644
index 0000000000..ecb05b120a
--- /dev/null
+++ b/tests/Unit/CountryHelpersTest.php
@@ -0,0 +1,42 @@
+toBe('πΊπΈ');
+ });
+
+ it('is case-insensitive', function () {
+ expect(countryFlagEmoji('us'))->toBe('πΊπΈ');
+ });
+
+ it('returns the globe fallback for invalid input', function (?string $input) {
+ expect(countryFlagEmoji($input))->toBe('π');
+ })->with([
+ 'null' => [null],
+ 'empty' => [''],
+ 'three letters' => ['USA'],
+ 'non-letters' => ['1!'],
+ 'single letter' => ['U'],
+ ]);
+});
+
+describe('countryName', function () {
+ it('returns the English region name for a valid uppercase code', function () {
+ expect(countryName('US'))->toBe('United States');
+ });
+
+ it('is case-insensitive', function () {
+ expect(countryName('us'))->toBe('United States');
+ });
+
+ it('returns Unknown for invalid input', function (?string $input) {
+ expect(countryName($input))->toBe('Unknown');
+ })->with([
+ 'null' => [null],
+ 'empty' => [''],
+ 'unresolvable ZZ' => ['ZZ'],
+ 'unresolvable XX' => ['XX'],
+ 'three letters' => ['USA'],
+ 'non-letters' => ['1!'],
+ ]);
+});
diff --git a/tests/Unit/DatabaseImport/CleanupDatabaseImportTest.php b/tests/Unit/DatabaseImport/CleanupDatabaseImportTest.php
new file mode 100644
index 0000000000..895440e7a0
--- /dev/null
+++ b/tests/Unit/DatabaseImport/CleanupDatabaseImportTest.php
@@ -0,0 +1,103 @@
+ 'postgres-abc',
+ 'containerTmpPath' => '/tmp/restore_op',
+ 'scriptPath' => '/tmp/restore_op.sh',
+ 'serverId' => 1,
+ ], $overrides);
+}
+
+test('the finished event keeps the payload and does not import Server', function () {
+ $data = importCleanupPayload(['serverTmpPath' => '/tmp/database-import-op']);
+ $event = new DatabaseImportFinished($data);
+
+ expect($event->data)->toBe($data)
+ ->and(file_get_contents(app_path('Events/DatabaseImportFinished.php')))
+ ->not->toContain('instant_remote_process')
+ ->not->toContain('use App\Models\Server');
+});
+
+test('the cleanup listener is queued and discovered', function () {
+ expect(class_implements(CleanupDatabaseImport::class))->toContain(ShouldQueue::class);
+
+ $listener = new CleanupDatabaseImport;
+ expect($listener->tries)->toBe(3)
+ ->and($listener->backoff)->toBe([5, 15, 30]);
+
+ Event::fake();
+ Event::assertListening(DatabaseImportFinished::class, CleanupDatabaseImport::class);
+});
+
+test('builds S3, upload, and server-path cleanup commands', function () {
+ $listener = new CleanupDatabaseImport;
+
+ expect($listener->commands(importCleanupPayload([
+ 'containerName' => 's3-restore-op',
+ 'serverTmpPath' => '/tmp/s3-restore-op',
+ 'credentialTmpPath' => '/tmp/s3-restore-op.env',
+ ])))->toBe([
+ 'docker rm -f '.escapeshellarg('s3-restore-op').' 2>/dev/null || true',
+ 'rm -f '.escapeshellarg('/tmp/s3-restore-op').' 2>/dev/null || true',
+ 'rm -f '.escapeshellarg('/tmp/s3-restore-op.env').' 2>/dev/null || true',
+ 'docker exec '.escapeshellarg('postgres-abc').' rm -f '.escapeshellarg('/tmp/restore_op').' 2>/dev/null || true',
+ 'docker exec '.escapeshellarg('postgres-abc').' rm -f '.escapeshellarg('/tmp/restore_op.sh').' 2>/dev/null || true',
+ ]);
+
+ expect($listener->commands(importCleanupPayload([
+ 'serverTmpPath' => '/tmp/database-import-op',
+ ])))->toBe([
+ 'rm -f '.escapeshellarg('/tmp/database-import-op').' 2>/dev/null || true',
+ 'docker exec '.escapeshellarg('postgres-abc').' rm -f '.escapeshellarg('/tmp/restore_op').' 2>/dev/null || true',
+ 'docker exec '.escapeshellarg('postgres-abc').' rm -f '.escapeshellarg('/tmp/restore_op.sh').' 2>/dev/null || true',
+ ]);
+
+ expect($listener->commands(importCleanupPayload()))->toBe([
+ 'docker exec '.escapeshellarg('postgres-abc').' rm -f '.escapeshellarg('/tmp/restore_op').' 2>/dev/null || true',
+ 'docker exec '.escapeshellarg('postgres-abc').' rm -f '.escapeshellarg('/tmp/restore_op.sh').' 2>/dev/null || true',
+ ]);
+});
+
+test('omits unsafe paths, missing container execs, and empty payloads', function () {
+ $listener = new CleanupDatabaseImport;
+
+ expect($listener->commands(importCleanupPayload([
+ 'containerName' => 's3-restore-op',
+ 'serverTmpPath' => '/tmp/../etc/passwd',
+ 'credentialTmpPath' => '/tmp/../etc/shadow',
+ 'containerTmpPath' => '/etc/shadow',
+ 'scriptPath' => '/tmp/../../etc/shadow',
+ ])))->toBe([
+ 'docker rm -f '.escapeshellarg('s3-restore-op').' 2>/dev/null || true',
+ ]);
+
+ expect($listener->commands([
+ 'serverTmpPath' => '/tmp/database-import-op',
+ 'containerTmpPath' => '/tmp/restore_op',
+ 'scriptPath' => '/tmp/restore_op.sh',
+ ]))->toBe([
+ 'rm -f '.escapeshellarg('/tmp/database-import-op').' 2>/dev/null || true',
+ ]);
+
+ expect($listener->commands([]))->toBe([]);
+});
+
+test('handle skips remote process when the server is missing', function () {
+ $event = new DatabaseImportFinished(importCleanupPayload([
+ 'serverId' => 999999,
+ 'containerName' => 's3-restore-op',
+ ]));
+
+ expect(fn () => (new CleanupDatabaseImport)->handle($event))->not->toThrow(Throwable::class);
+});
diff --git a/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php
new file mode 100644
index 0000000000..87b37b6668
--- /dev/null
+++ b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php
@@ -0,0 +1,129 @@
+shouldReceive('getMorphClass')->andReturn($class);
+ if ($class === ServiceDatabase::class) {
+ $resource->shouldReceive('databaseType')->andReturn($databaseType);
+ }
+
+ return $resource;
+}
+
+test('builds database-specific restore commands', function (string $class, ?string $type, string $needle) {
+ $builder = new DatabaseImportCommandBuilder;
+
+ $command = $builder->buildRestoreCommand(importResource($class, $type), '/tmp/restore file', false);
+
+ expect($command)->toContain($needle)->toContain("'/tmp/restore file'");
+})->with([
+ 'postgresql' => [StandalonePostgresql::class, null, 'pg_restore'],
+ 'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER'],
+ 'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER'],
+ 'mongodb' => [StandaloneMongodb::class, null, 'mongorestore'],
+ 'service postgres' => [ServiceDatabase::class, 'postgresql', 'pg_restore'],
+ 'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER'],
+ 'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER'],
+ 'service mongo' => [ServiceDatabase::class, 'mongodb', 'mongorestore'],
+]);
+
+test('decompresses gzip backups for single-database mysql and mariadb restores', function (string $class, ?string $type, string $client) {
+ $builder = new DatabaseImportCommandBuilder;
+
+ $command = $builder->buildRestoreCommand(importResource($class, $type), '/tmp/restore file.sql.gz', false);
+
+ expect($command)->toBe(
+ "(gunzip -cf '/tmp/restore file.sql.gz' 2>/dev/null || cat '/tmp/restore file.sql.gz') | {$client}"
+ );
+})->with([
+ 'mysql' => [StandaloneMysql::class, null, 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
+ 'mariadb' => [StandaloneMariadb::class, null, 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
+ 'service mysql' => [ServiceDatabase::class, 'mysql', 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'],
+ 'service mariadb' => [ServiceDatabase::class, 'mariadb', 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'],
+]);
+
+test('builds dump-all commands and postgres safety scan', function () {
+ $builder = new DatabaseImportCommandBuilder;
+ $postgres = importResource(StandalonePostgresql::class);
+
+ $safety = $builder->buildPostgresSafetyCommand($postgres, 'postgres-safe', '/tmp/dump.sql.gz');
+ $script = $builder->buildPostgresRestoreScanScript($postgres, '/tmp/dump.sql.gz');
+
+ expect($builder->buildRestoreCommand($postgres, '/tmp/dump.sql.gz', true))
+ ->toContain('pg_terminate_backend')
+ ->toContain("gunzip -cf '/tmp/dump.sql.gz'")
+ ->and($safety)
+ ->toContain('COPY ... PROGRAM')
+ ->toContain('docker exec postgres-safe')
+ ->toContain('pg_restore -l')
+ ->toContain('pg_restore -f -')
+ ->toContain('unable to inspect custom archive')
+ ->not->toContain('then exit 0')
+ ->and($script)
+ ->toContain("tr '\\n\\r\\t'");
+});
+
+test('postgres safety command is null for non-postgres databases', function () {
+ $builder = new DatabaseImportCommandBuilder;
+
+ expect($builder->buildPostgresSafetyCommand(importResource(StandaloneMysql::class), 'mysql-test', '/tmp/restore_test'))
+ ->toBeNull();
+});
+
+test('dump-all mysql and mariadb commands use valid shell parameter expansions', function (string $class, string $binary, string $prefix) {
+ $builder = new DatabaseImportCommandBuilder;
+
+ $command = $builder->buildRestoreCommand(importResource($class), '/tmp/dump.sql.gz', true);
+
+ $rootPassword = '${'.$prefix.'_ROOT_PASSWORD}';
+ $database = '${'.$prefix.'_DATABASE:-default}';
+
+ expect($command)
+ ->toContain($binary)
+ ->toContain("gunzip -cf '/tmp/dump.sql.gz'")
+ ->toContain('-p'.$rootPassword)
+ ->toContain('CREATE DATABASE IF NOT EXISTS \`'.$database.'\`')
+ ->and(substr_count($command, $rootPassword))->toBe(6)
+ ->and(substr_count($command, $database))->toBe(2)
+ ->and($command)->not->toContain('${{');
+})->with([
+ 'mysql' => [StandaloneMysql::class, 'mysql', 'MYSQL'],
+ 'mariadb' => [StandaloneMariadb::class, 'mariadb', 'MARIADB'],
+]);
+
+test('stops PostgreSQL restores on the first error without replacing existing objects by default', function () {
+ $builder = new DatabaseImportCommandBuilder;
+ $postgres = importResource(StandalonePostgresql::class);
+
+ expect($builder->buildRestoreCommand($postgres, '/tmp/backup.dump', false, false))
+ ->toContain('--exit-on-error')
+ ->not->toContain('--clean')
+ ->not->toContain('--if-exists');
+});
+
+test('replaces existing PostgreSQL objects when requested', function () {
+ $builder = new DatabaseImportCommandBuilder;
+ $postgres = importResource(StandalonePostgresql::class);
+
+ expect($builder->buildRestoreCommand($postgres, '/tmp/backup.dump', false, true))
+ ->toContain('--clean')
+ ->toContain('--if-exists')
+ ->toContain('--exit-on-error');
+});
+
+test('rejects unsupported database types', function () {
+ $builder = new DatabaseImportCommandBuilder;
+ $redis = importResource(StandaloneRedis::class);
+
+ expect(fn () => $builder->buildRestoreCommand($redis, '/tmp/backup', false))
+ ->toThrow(InvalidArgumentException::class, 'not supported');
+});
diff --git a/tests/Unit/DatabaseImportOpenApiTest.php b/tests/Unit/DatabaseImportOpenApiTest.php
new file mode 100644
index 0000000000..94aa6f81d7
--- /dev/null
+++ b/tests/Unit/DatabaseImportOpenApiTest.php
@@ -0,0 +1,82 @@
+toHaveKey('/databases/{uuid}/imports/uploads')
+ ->toHaveKey('/databases/{uuid}/imports')
+ ->toHaveKey('/databases/{uuid}/imports/{activity_id}')
+ ->toHaveKey('/services/{uuid}/databases/{database_uuid}/imports/uploads')
+ ->toHaveKey('/services/{uuid}/databases/{database_uuid}/imports')
+ ->toHaveKey('/services/{uuid}/databases/{database_uuid}/imports/{activity_id}');
+
+ foreach ($document['components']['schemas']['DatabaseImportRequest']['oneOf'] as $source) {
+ expect($source['properties']['replace_existing'])
+ ->toMatchArray(['type' => 'boolean', 'default' => false]);
+ }
+
+ $statusRef = [
+ 'description' => 'Import status',
+ 'content' => [
+ 'application/json' => [
+ 'schema' => [
+ '$ref' => '#/components/schemas/DatabaseImportStatus',
+ ],
+ ],
+ ],
+ ];
+
+ expect($document['paths']['/databases/{uuid}/imports/{activity_id}']['get']['responses']['200'])
+ ->toMatchArray($statusRef)
+ ->and($document['paths']['/services/{uuid}/databases/{database_uuid}/imports/{activity_id}']['get']['responses']['200'])
+ ->toMatchArray($statusRef)
+ ->and($document['components']['schemas'])
+ ->toHaveKey('DatabaseImportStatus');
+});
+
+test('documents path parameters for database import endpoints', function () {
+ $document = json_decode((string) file_get_contents(__DIR__.'/../../openapi.json'), true, flags: JSON_THROW_ON_ERROR);
+
+ $operations = [
+ ['/databases/{uuid}/imports/uploads', 'post', ['uuid']],
+ ['/databases/{uuid}/imports', 'post', ['uuid']],
+ ['/databases/{uuid}/imports/{activity_id}', 'get', ['uuid', 'activity_id']],
+ ['/services/{uuid}/databases/{database_uuid}/imports/uploads', 'post', ['uuid', 'database_uuid']],
+ ['/services/{uuid}/databases/{database_uuid}/imports', 'post', ['uuid', 'database_uuid']],
+ ['/services/{uuid}/databases/{database_uuid}/imports/{activity_id}', 'get', ['uuid', 'database_uuid', 'activity_id']],
+ ];
+
+ foreach ($operations as [$path, $method, $expectedNames]) {
+ $parameters = $document['paths'][$path][$method]['parameters'] ?? [];
+ $pathParameters = collect($parameters)
+ ->filter(fn (array $parameter): bool => ($parameter['in'] ?? null) === 'path')
+ ->map(fn (array $parameter): string => $parameter['name'])
+ ->values()
+ ->all();
+
+ expect($pathParameters)->toEqual($expectedNames);
+ }
+});
+
+test('constrains additional properties on each database import source branch', function () {
+ $document = json_decode((string) file_get_contents(__DIR__.'/../../openapi.json'), true, flags: JSON_THROW_ON_ERROR);
+ $schema = $document['components']['schemas']['DatabaseImportRequest'];
+
+ expect($schema)->not->toHaveKey('additionalProperties');
+
+ $expectedProperties = [
+ ['source', 'upload_id', 'dump_all', 'replace_existing'],
+ ['source', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing'],
+ ['source', 'path', 'dump_all', 'replace_existing'],
+ ];
+
+ expect($schema['oneOf'])->toHaveCount(count($expectedProperties));
+
+ foreach ($schema['oneOf'] as $index => $source) {
+ expect($source['additionalProperties'])->toBeFalse()
+ ->and($source['properties'])->toHaveKeys($expectedProperties[$index])
+ ->and($source['properties']['replace_existing'])
+ ->toMatchArray(['type' => 'boolean', 'default' => false]);
+ }
+});
diff --git a/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php b/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php
new file mode 100644
index 0000000000..7ee295e34f
--- /dev/null
+++ b/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php
@@ -0,0 +1,83 @@
+toContain(...$expected)
+ ->not->toContain(...$unexpected);
+})->with([
+ 'clickhouse' => [
+ 'StartClickhouse',
+ ['$this->resolvedClickhouseUser', '$this->resolvedClickhousePassword'],
+ ['$this->database->clickhouse_admin_user, \'--password\'', '$this->database->clickhouse_admin_password, \'--query\''],
+ ],
+ 'dragonfly' => [
+ 'StartDragonfly',
+ ['$this->resolvedRedisPassword'],
+ ['$this->database->dragonfly_password, \'ping\'', 'requirepass {$this->database->dragonfly_password}'],
+ ],
+ 'keydb' => [
+ 'StartKeydb',
+ ['$this->resolvedRedisPassword'],
+ ['$this->database->keydb_password, \'ping\'', 'requirepass {$this->database->keydb_password}'],
+ ],
+ 'mongodb' => [
+ 'StartMongodb',
+ ['$this->resolvedMongoDatabase', '$this->resolvedMongoUsername', '$this->resolvedMongoPassword'],
+ ['json_encode($this->database->mongo_initdb_database', 'json_encode($this->database->mongo_initdb_root_username', 'json_encode($this->database->mongo_initdb_root_password'],
+ ],
+ 'mysql' => [
+ 'StartMysql',
+ ['$this->resolvedMysqlRootPassword'],
+ ['-p{$this->database->mysql_root_password}'],
+ ],
+ 'postgresql' => [
+ 'StartPostgresql',
+ ['$this->resolvedPostgresUser', '$this->resolvedPostgresDatabase'],
+ ['$this->database->postgres_user, \'-d\'', '$this->database->postgres_db, \'-c\''],
+ ],
+]);
+
+it('runs database start commands without persisting them through remote process', function (string $action) {
+ $source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
+
+ expect($source)
+ ->toContain('ExecutesDatabaseStartCommands')
+ ->toContain('executeDatabaseStartCommands(')
+ ->not->toContain('return remote_process(');
+})->with([
+ 'StartClickhouse',
+ 'StartDragonfly',
+ 'StartKeydb',
+ 'StartMariadb',
+ 'StartMongodb',
+ 'StartMysql',
+ 'StartPostgresql',
+ 'StartRedis',
+]);
+
+it('queues database starts with identifiers instead of generated commands', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDatabase.php');
+
+ expect($source)
+ ->toContain('DatabaseStartJob::dispatch(')
+ ->not->toContain('StartPostgresql::run(')
+ ->not->toContain('StartRedis::run(');
+});
+
+it('keeps raw secret values separate from compose environment formatting', function (string $action, array $rawAssignments) {
+ $source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
+
+ expect($source)
+ ->toContain('$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);')
+ ->toContain('$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);')
+ ->toContain('$environment_variables->push($env->key.\'=\'.$resolvedValue);')
+ ->toContain(...$rawAssignments);
+})->with([
+ 'clickhouse' => ['StartClickhouse', ['$this->resolvedClickhouseUser = $rawValue;', '$this->resolvedClickhousePassword = $rawValue;']],
+ 'dragonfly' => ['StartDragonfly', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']],
+ 'keydb' => ['StartKeydb', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']],
+ 'mongodb' => ['StartMongodb', ['$this->resolvedMongoUsername = $rawValue;', '$this->resolvedMongoPassword = $rawValue;', '$this->resolvedMongoDatabase = $rawValue;', 'json_encode($this->resolvedMongoPassword']],
+ 'mysql' => ['StartMysql', ['$this->resolvedMysqlRootPassword = $rawValue;']],
+ 'postgresql' => ['StartPostgresql', ['$this->resolvedPostgresUser = $rawValue;', '$this->resolvedPostgresDatabase = $rawValue;']],
+]);
diff --git a/tests/Unit/DatabaseStartJobTest.php b/tests/Unit/DatabaseStartJobTest.php
new file mode 100644
index 0000000000..e6333ea769
--- /dev/null
+++ b/tests/Unit/DatabaseStartJobTest.php
@@ -0,0 +1,36 @@
+failed(new RuntimeException('Database start failed.'));
+
+ Event::assertDispatched(
+ DatabaseStatusChanged::class,
+ fn (DatabaseStatusChanged $event): bool => $event->userId === 42,
+ );
+});
+
+it('targets normal database start status changes to the initiating user', function () {
+ $source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseStartJob.php');
+
+ expect($source)
+ ->toContain('event(new DatabaseStatusChanged($this->userId));')
+ ->not->toContain('event(new DatabaseStatusChanged($database));');
+});
diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
index b7901abb68..140be57643 100644
--- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
+++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
@@ -334,13 +334,13 @@ it('detects environment variable value changes without exposing secret values',
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
- ->and($change['display_summary'])->toBe('Changed')
- ->and($change['old_display_value'])->toBe('β’β’β’β’β’β’β’β’')
- ->and($change['new_display_value'])->toBe('β’β’β’β’β’β’β’β’')
- ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
+ ->and($change['display_summary'])->toBeNull()
+ ->and($change['old_display_value'])->toBe('old-secret')
+ ->and($change['new_display_value'])->toBe('new-secret')
+ ->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret');
});
-it('describes added environment variables as set without exposing secret values', function () {
+it('describes added unlocked environment variables with their value', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
@@ -361,6 +361,6 @@ it('describes added environment variables as set without exposing secret values'
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('-')
- ->and($change['new_display_value'])->toBe('β’β’β’β’β’β’β’β’')
- ->and(json_encode($diff->toArray()))->not->toContain('new-secret');
+ ->and($change['new_display_value'])->toBe('new-secret')
+ ->and(json_encode($diff->toArray()))->toContain('new-secret');
});
diff --git a/tests/Unit/Livewire/Database/S3RestoreTest.php b/tests/Unit/Livewire/Database/S3RestoreTest.php
index e961f1317c..a0acf229d8 100644
--- a/tests/Unit/Livewire/Database/S3RestoreTest.php
+++ b/tests/Unit/Livewire/Database/S3RestoreTest.php
@@ -132,23 +132,23 @@ test('dump-all PostgreSQL restore selects the client for the dump format', funct
test('buildRestoreCommand handles MySQL without dumpAll', function () {
$component = importFormWithResource('App\Models\StandaloneMysql');
$component->dumpAll = false;
- $component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
- expect($result)->toContain("< '/tmp/test.dump'");
+ expect($result)->toContain("(gunzip -cf '/tmp/test.dump' 2>/dev/null || cat '/tmp/test.dump') | mysql");
+ expect($result)->not->toContain("< '/tmp/test.dump'");
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$component = importFormWithResource('App\Models\StandaloneMariadb');
$component->dumpAll = false;
- $component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
- expect($result)->toContain("< '/tmp/test.dump'");
+ expect($result)->toContain("(gunzip -cf '/tmp/test.dump' 2>/dev/null || cat '/tmp/test.dump') | mariadb");
+ expect($result)->not->toContain("< '/tmp/test.dump'");
});
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
diff --git a/tests/Unit/OauthSettingTest.php b/tests/Unit/OauthSettingTest.php
new file mode 100644
index 0000000000..48fb50c375
--- /dev/null
+++ b/tests/Unit/OauthSettingTest.php
@@ -0,0 +1,30 @@
+ 'oidc']);
+ expect($setting->couldBeEnabled())->toBeFalse();
+
+ $setting->fill([
+ 'client_id' => 'client-id',
+ 'client_secret' => 'secret',
+ 'base_url' => 'https://idp.example.com',
+ ]);
+
+ expect($setting->couldBeEnabled())->toBeTrue();
+});
+
+it('returns configured scopes and custom login label', function () {
+ $setting = new OauthSetting([
+ 'provider' => 'oidc',
+ 'scopes' => 'openid email profile groups',
+ 'custom_label' => 'Login with Okta',
+ ]);
+
+ expect($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups'])
+ ->and($setting->loginLabel())->toBe('Login with Okta');
+});
diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php
new file mode 100644
index 0000000000..18c358fd13
--- /dev/null
+++ b/tests/Unit/OidcDiscoveryServiceTest.php
@@ -0,0 +1,119 @@
+ Http::response([
+ 'issuer' => 'https://idp.example.com',
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ 'userinfo_endpoint' => 'https://idp.example.com/userinfo',
+ 'jwks_uri' => 'https://idp.example.com/jwks',
+ ]),
+ 'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]),
+ ]);
+
+ $service = app(OidcDiscoveryService::class);
+
+ $discovery = $service->discover('https://idp.example.com');
+ $jwks = $service->jwks($discovery->jwksUri);
+
+ expect($discovery->issuer)->toBe('https://idp.example.com')
+ ->and($jwks['keys'][0]['kid'])->toBe('one');
+
+ Http::assertSentCount(2);
+
+ $service->discover('https://idp.example.com');
+ $service->jwks('https://idp.example.com/jwks');
+
+ Http::assertSentCount(2);
+});
+
+it('does not cache discovery documents with mismatched issuers', function () {
+ Cache::flush();
+ Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration')
+ ->push([
+ 'issuer' => 'https://evil.example.com',
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ 'userinfo_endpoint' => 'https://idp.example.com/userinfo',
+ 'jwks_uri' => 'https://idp.example.com/jwks',
+ ])
+ ->push([
+ 'issuer' => 'https://idp.example.com',
+ 'authorization_endpoint' => 'https://idp.example.com/auth',
+ 'token_endpoint' => 'https://idp.example.com/token',
+ 'userinfo_endpoint' => 'https://idp.example.com/userinfo',
+ 'jwks_uri' => 'https://idp.example.com/jwks',
+ ]);
+
+ $service = app(OidcDiscoveryService::class);
+ $cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com');
+
+ expect(fn () => $service->discover('https://idp.example.com'))
+ ->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.')
+ ->and(Cache::has($cacheKey))->toBeFalse()
+ ->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com');
+
+ Http::assertSentCount(2);
+});
+
+it('refetches jwks once on forced refresh to pick up rotated keys', function () {
+ Cache::flush();
+ Http::fakeSequence('https://idp.example.com/jwks')
+ ->push(['keys' => [['kid' => 'old']]])
+ ->push(['keys' => [['kid' => 'new']]]);
+
+ $service = app(OidcDiscoveryService::class);
+
+ expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old');
+
+ // Forced refresh bypasses the cache and sees the rotated key.
+ expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new');
+ Http::assertSentCount(2);
+
+ // Cooldown prevents a second immediate upstream fetch; cached value returned.
+ expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new');
+ Http::assertSentCount(2);
+});
+
+it('rejects invalid discovery and jwks payloads', function () {
+ Cache::flush();
+ Http::fake([
+ 'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']),
+ ]);
+
+ app(OidcDiscoveryService::class)->discover('https://bad.example.com');
+})->throws(OidcDiscoveryException::class);
+
+it('rejects jwks responses without keys', function () {
+ Cache::flush();
+ Http::fake([
+ 'https://idp.example.com/jwks' => Http::response(['empty' => true]),
+ ]);
+
+ app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks');
+})->throws(OidcJwksException::class);
+
+it('rejects non-https issuer urls', function () {
+ Cache::flush();
+ Http::fake();
+
+ app(OidcDiscoveryService::class)->discover('http://idp.example.com');
+})->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.');
+
+it('rejects non-https jwks uris', function () {
+ Cache::flush();
+ Http::fake();
+
+ app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks');
+})->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.');
diff --git a/tests/Unit/OidcProviderPkceTest.php b/tests/Unit/OidcProviderPkceTest.php
new file mode 100644
index 0000000000..b92ff58ffe
--- /dev/null
+++ b/tests/Unit/OidcProviderPkceTest.php
@@ -0,0 +1,148 @@
+getAuthUrl($state);
+ }
+}
+
+function oidc_provider_discovery_document(): OidcDiscoveryDocument
+{
+ return new OidcDiscoveryDocument(
+ issuer: 'https://idp.example.com',
+ authorizationEndpoint: 'https://idp.example.com/oauth2/authorize',
+ tokenEndpoint: 'https://idp.example.com/oauth2/token',
+ userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo',
+ jwksUri: 'https://idp.example.com/.well-known/jwks.json',
+ );
+}
+
+function oidc_provider_session(): Store
+{
+ $session = new Store('testing', new ArraySessionHandler(1200));
+ $session->start();
+
+ return $session;
+}
+
+function oidc_provider_request(Store $session, string $state = 'state-value'): Request
+{
+ $request = Request::create('/auth/oidc/callback', 'GET', ['state' => $state]);
+ $request->setLaravelSession($session);
+
+ return $request;
+}
+
+function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl
+{
+ /** @var OidcDiscoveryService&MockInterface $discoveryService */
+ $discoveryService = Mockery::mock(OidcDiscoveryService::class);
+ $discoveryService->shouldReceive('discover')
+ ->byDefault()
+ ->with('https://idp.example.com')
+ ->andReturn(oidc_provider_discovery_document());
+
+ /** @var OidcTokenValidator&MockInterface $tokenValidator */
+ $tokenValidator = Mockery::mock(OidcTokenValidator::class);
+
+ return (new TestOidcProviderWithExposedAuthUrl(
+ $request,
+ $discoveryService,
+ $tokenValidator,
+ 'client-id',
+ 'client-secret',
+ 'https://coolify.example.com/auth/oidc/callback',
+ ))->setConfig(new OidcConfig(
+ issuerUrl: 'https://idp.example.com',
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ redirectUri: 'https://coolify.example.com/auth/oidc/callback',
+ usePkce: true,
+ ));
+}
+
+it('stores oidc nonce and pkce verifier with a ten minute expiry', function () {
+ Carbon::setTestNow('2026-06-15 12:00:00');
+
+ try {
+ $session = oidc_provider_session();
+ $provider = oidc_provider(oidc_provider_request($session));
+
+ $provider->authUrlForState('state-value');
+
+ $nonceEntry = $session->get('oidc.nonce.state-value');
+ $verifierEntry = $session->get('oidc.code_verifier.state-value');
+
+ expect($nonceEntry)->toBeArray()
+ ->and($nonceEntry['value'])->toBeString()->not->toBeEmpty()
+ ->and($nonceEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp)
+ ->and($verifierEntry)->toBeArray()
+ ->and($verifierEntry['value'])->toBeString()->not->toBeEmpty()
+ ->and($verifierEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp);
+ } finally {
+ Carbon::setTestNow();
+ }
+});
+
+it('sends a fresh oidc pkce verifier during token exchange', function () {
+ $session = oidc_provider_session();
+ $session->put('oidc.code_verifier.state-value', [
+ 'value' => 'fresh-verifier',
+ 'expires_at' => now()->addMinute()->timestamp,
+ ]);
+
+ $provider = oidc_provider(oidc_provider_request($session));
+ $history = [];
+ $handler = HandlerStack::create(new MockHandler([
+ new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)),
+ ]));
+ $handler->push(Middleware::history($history));
+ $provider->setHttpClient(new Client(['handler' => $handler]));
+
+ $provider->getAccessTokenResponse('authorization-code');
+
+ parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields);
+
+ expect($tokenRequestFields['code_verifier'] ?? null)->toBe('fresh-verifier')
+ ->and($session->has('oidc.code_verifier.state-value'))->toBeFalse();
+});
+
+it('throws a session expired error for an expired oidc pkce verifier during token exchange', function () {
+ $session = oidc_provider_session();
+ $session->put('oidc.code_verifier.state-value', [
+ 'value' => 'expired-verifier',
+ 'expires_at' => now()->subSecond()->timestamp,
+ ]);
+
+ $provider = oidc_provider(oidc_provider_request($session));
+ $history = [];
+ $handler = HandlerStack::create(new MockHandler([
+ new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)),
+ ]));
+ $handler->push(Middleware::history($history));
+ $provider->setHttpClient(new Client(['handler' => $handler]));
+
+ $provider->getAccessTokenResponse('authorization-code');
+})->throws(OidcException::class, 'OIDC login session expired. Please try again.');
diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php
new file mode 100644
index 0000000000..9b1d9a24c3
--- /dev/null
+++ b/tests/Unit/OidcTokenValidatorTest.php
@@ -0,0 +1,187 @@
+ 2048,
+ 'private_key_type' => OPENSSL_KEYTYPE_RSA,
+ ]);
+
+ openssl_pkey_export($privateKey, $privatePem);
+ $details = openssl_pkey_get_details($privateKey);
+
+ return [
+ 'private_pem' => $privatePem,
+ 'jwks' => [
+ 'keys' => [[
+ 'kty' => 'RSA',
+ 'kid' => $kid,
+ 'alg' => 'RS256',
+ 'use' => 'sig',
+ 'n' => oidc_base64url($details['rsa']['n']),
+ 'e' => oidc_base64url($details['rsa']['e']),
+ ]],
+ ],
+ ];
+}
+
+function oidc_token(array $claims, string $privatePem, string $kid = 'test-key', string $algorithm = 'RS256'): string
+{
+ $header = oidc_base64url(json_encode(['alg' => $algorithm, 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR));
+ $payload = oidc_base64url(json_encode($claims, JSON_THROW_ON_ERROR));
+ $signatureInput = $header.'.'.$payload;
+ openssl_sign($signatureInput, $signature, $privatePem, OPENSSL_ALGO_SHA256);
+
+ return $signatureInput.'.'.oidc_base64url($signature);
+}
+
+function oidc_discovery(): OidcDiscoveryDocument
+{
+ return new OidcDiscoveryDocument(
+ issuer: 'https://idp.example.com',
+ authorizationEndpoint: 'https://idp.example.com/oauth2/authorize',
+ tokenEndpoint: 'https://idp.example.com/oauth2/token',
+ userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo',
+ jwksUri: 'https://idp.example.com/.well-known/jwks.json',
+ );
+}
+
+it('validates a well formed RS256 id token', function () {
+ $keyset = oidc_keyset();
+ $now = time();
+ $token = oidc_token([
+ 'iss' => 'https://idp.example.com',
+ 'aud' => 'client-id',
+ 'sub' => 'okta-user-1',
+ 'iat' => $now,
+ 'exp' => $now + 600,
+ 'nonce' => 'expected-nonce',
+ 'email' => 'User@Example.com',
+ ], $keyset['private_pem']);
+
+ $claims = app(OidcTokenValidator::class)->validate(
+ idToken: $token,
+ discovery: oidc_discovery(),
+ jwks: $keyset['jwks'],
+ clientId: 'client-id',
+ expectedNonce: 'expected-nonce',
+ );
+
+ expect($claims['sub'])->toBe('okta-user-1')
+ ->and($claims['email'])->toBe('User@Example.com');
+});
+
+it('rejects invalid token claims', function (array $claimOverrides, string $message) {
+ $keyset = oidc_keyset();
+ $now = time();
+ $claims = array_merge([
+ 'iss' => 'https://idp.example.com',
+ 'aud' => 'client-id',
+ 'sub' => 'okta-user-1',
+ 'iat' => $now,
+ 'exp' => $now + 600,
+ 'nonce' => 'expected-nonce',
+ ], $claimOverrides);
+
+ $token = oidc_token($claims, $keyset['private_pem']);
+
+ app(OidcTokenValidator::class)->validate(
+ idToken: $token,
+ discovery: oidc_discovery(),
+ jwks: $keyset['jwks'],
+ clientId: 'client-id',
+ expectedNonce: 'expected-nonce',
+ );
+})->throws(OidcTokenException::class)->with([
+ 'issuer mismatch' => [['iss' => 'https://evil.example.com'], 'issuer'],
+ 'audience mismatch' => [['aud' => 'other-client'], 'audience'],
+ 'azp missing for multi audience' => [['aud' => ['client-id', 'other-client']], 'azp'],
+ 'azp mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'],
+ 'expired token' => [['exp' => time() - 3600], 'expired'],
+ 'future issued at' => [['iat' => time() + 3600], 'issued'],
+ 'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'],
+ 'missing subject' => [['sub' => null], 'subject'],
+ 'empty subject' => [['sub' => ''], 'subject'],
+ 'non-string subject' => [['sub' => 123], 'subject'],
+]);
+
+it('rejects a bad signature and unknown key id', function (string $kid) {
+ $keyset = oidc_keyset('test-key');
+ $otherKeyset = oidc_keyset($kid);
+ $now = time();
+ $token = oidc_token([
+ 'iss' => 'https://idp.example.com',
+ 'aud' => 'client-id',
+ 'sub' => 'okta-user-1',
+ 'iat' => $now,
+ 'exp' => $now + 600,
+ 'nonce' => 'expected-nonce',
+ ], $otherKeyset['private_pem'], $kid);
+
+ app(OidcTokenValidator::class)->validate(
+ idToken: $token,
+ discovery: oidc_discovery(),
+ jwks: $keyset['jwks'],
+ clientId: 'client-id',
+ expectedNonce: 'expected-nonce',
+ );
+})->throws(OidcTokenException::class)->with([
+ 'same kid with bad signature' => ['test-key'],
+ 'unknown kid' => ['other-key'],
+]);
+
+it('rejects disallowed algorithms', function () {
+ $keyset = oidc_keyset();
+ $now = time();
+ $token = oidc_token([
+ 'iss' => 'https://idp.example.com',
+ 'aud' => 'client-id',
+ 'sub' => 'okta-user-1',
+ 'iat' => $now,
+ 'exp' => $now + 600,
+ ], $keyset['private_pem'], algorithm: 'HS256');
+
+ app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
+})->throws(OidcTokenException::class);
+
+it('throws a dedicated exception when the signing key is unknown', function () {
+ $keyset = oidc_keyset('current-key');
+ $token = oidc_token([
+ 'iss' => 'https://idp.example.com',
+ 'aud' => 'client-id',
+ 'sub' => 'okta-user-1',
+ 'iat' => time(),
+ 'exp' => time() + 600,
+ ], $keyset['private_pem'], 'rotated-key');
+
+ app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
+})->throws(OidcSigningKeyNotFoundException::class);
+
+it('rejects a jwks key not designated for signing', function () {
+ $keyset = oidc_keyset();
+ $keyset['jwks']['keys'][0]['use'] = 'enc';
+ $now = time();
+ $token = oidc_token([
+ 'iss' => 'https://idp.example.com',
+ 'aud' => 'client-id',
+ 'sub' => 'okta-user-1',
+ 'iat' => $now,
+ 'exp' => $now + 600,
+ ], $keyset['private_pem']);
+
+ // An encryption-only key is dropped from the keyset, so the kid no longer resolves.
+ app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
+})->throws(OidcTokenException::class);
diff --git a/tests/Unit/RemoteSecretReferencesTest.php b/tests/Unit/RemoteSecretReferencesTest.php
new file mode 100644
index 0000000000..c56a836370
--- /dev/null
+++ b/tests/Unit/RemoteSecretReferencesTest.php
@@ -0,0 +1,47 @@
+toBeTrue()
+ ->and(RemoteSecretReferences::containsReference('{{doppler.KEY}}'))->toBeFalse()
+ ->and(RemoteSecretReferences::containsReference('{{infisical.KEY}}'))->toBeFalse()
+ ->and(RemoteSecretReferences::containsReference('{{ vault.KEY }}'))->toBeTrue()
+ ->and(RemoteSecretReferences::containsReference('pre-{{vault.KEY}}-post'))->toBeTrue();
+});
+
+test('ignores the secret namespace, shared variables, and plain values', function () {
+ expect(RemoteSecretReferences::containsReference('{{secret.KEY}}'))->toBeFalse()
+ ->and(RemoteSecretReferences::containsReference('{{team.KEY}}'))->toBeFalse()
+ ->and(RemoteSecretReferences::containsReference('{{project.KEY}}'))->toBeFalse()
+ ->and(RemoteSecretReferences::containsReference('plain'))->toBeFalse()
+ ->and(RemoteSecretReferences::containsReference('$OTHER_VAR'))->toBeFalse()
+ ->and(RemoteSecretReferences::containsReference(null))->toBeFalse()
+ ->and(RemoteSecretReferences::containsReference(''))->toBeFalse();
+});
+
+test('extracts unique referenced keys in order', function () {
+ $value = 'a={{vault.A}} ignored={{doppler.B}} again={{vault.A}}';
+
+ expect(RemoteSecretReferences::referencedKeys($value))->toBe(['A']);
+});
+
+test('handles padded reference syntax consistently', function () {
+ expect(RemoteSecretReferences::referencedKeys('{{ vault.A }}'))->toBe(['A'])
+ ->and(RemoteSecretReferences::substitute('{{ vault.A }} {{ vault.MISSING }}', ['A' => 'value-a']))
+ ->toBe('value-a {{ vault.MISSING }}')
+ ->and(RemoteSecretReferences::missingKeys('{{ vault.A }} {{ vault.MISSING }}', ['A' => 'value-a']))
+ ->toBe(['MISSING']);
+});
+
+test('substitutes references and leaves unknown keys untouched', function () {
+ $secrets = ['A' => 'value-a'];
+
+ expect(RemoteSecretReferences::substitute('x={{vault.A}} y={{vault.MISSING}}', $secrets))
+ ->toBe('x=value-a y={{vault.MISSING}}');
+});
+
+test('reports missing keys', function () {
+ expect(RemoteSecretReferences::missingKeys('{{vault.A}}-{{vault.B}}', ['A' => '1']))->toBe(['B'])
+ ->and(RemoteSecretReferences::missingKeys('{{vault.A}}', ['A' => '1']))->toBe([]);
+});
diff --git a/tests/Unit/SecretManagerLayoutConsistencyTest.php b/tests/Unit/SecretManagerLayoutConsistencyTest.php
new file mode 100644
index 0000000000..82d7f82c34
--- /dev/null
+++ b/tests/Unit/SecretManagerLayoutConsistencyTest.php
@@ -0,0 +1,15 @@
+';
+ $secretManager = ' ';
+
+ expect($source)
+ ->toContain($environmentVariables, $secretManager)
+ ->and(strpos($source, $environmentVariables))->toBeLessThan(strpos($source, $secretManager));
+})->with([
+ 'application' => ['application', 'application'],
+ 'database' => ['database', 'database'],
+ 'service' => ['service', 'service'],
+]);
diff --git a/tests/Unit/SshMultiplexingDisableTest.php b/tests/Unit/SshMultiplexingDisableTest.php
index d2d4ae600f..4dedc7a768 100644
--- a/tests/Unit/SshMultiplexingDisableTest.php
+++ b/tests/Unit/SshMultiplexingDisableTest.php
@@ -23,6 +23,16 @@ class SshMultiplexingDisableTest extends TestCase
);
}
+ public function test_remote_shell_prefers_bash_and_falls_back_to_sh()
+ {
+ $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand');
+
+ $this->assertSame(
+ 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi',
+ $reflection->invoke(null)
+ );
+ }
+
public function test_generate_ssh_command_accepts_disable_multiplexing_parameter()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand');
diff --git a/tests/v4/Browser/TrafficAnalyticsTest.php b/tests/v4/Browser/TrafficAnalyticsTest.php
new file mode 100644
index 0000000000..ad4ba0a194
--- /dev/null
+++ b/tests/v4/Browser/TrafficAnalyticsTest.php
@@ -0,0 +1,54 @@
+stack = seedBrowserResourceStack();
+ $this->application = createBrowserApplication($this->stack, [
+ 'uuid' => 'app-traffic-analytics',
+ 'name' => 'Traffic App',
+ ]);
+});
+
+it('shows the disabled empty state on the application analytics tab', function () {
+ loginAndSkipBoarding();
+
+ $url = applicationConfigurationUrl(
+ $this->stack['project'],
+ $this->stack['environment'],
+ $this->application
+ );
+
+ $page = visit("{$url}/analytics");
+
+ $page->assertSee('Traffic App')
+ ->assertSee('Traffic analytics is not enabled')
+ ->assertSee('Enable Sentinel traffic analytics for this server to start collecting request analytics.')
+ ->screenshot(filename: 'application-analytics-disabled-empty-state');
+});
+
+it('shows the disabled empty state on the global analytics page', function () {
+ loginAndSkipBoarding();
+
+ $page = visit('/analytics');
+
+ $page->assertSee('Analytics')
+ ->assertSee('Traffic analytics is not enabled')
+ ->assertSee('Enable Sentinel traffic analytics on a server to see request analytics here.')
+ ->screenshot(filename: 'global-analytics-disabled-empty-state');
+});
+
+it('shows the disabled empty state on the dashboard traffic widget', function () {
+ $page = loginAndSkipBoarding();
+
+ $page->assertSee('Traffic analytics')
+ ->assertSee('Traffic analytics is not enabled')
+ ->assertSee('Enable Sentinel traffic analytics on a server to see a team-wide summary here.')
+ ->screenshot(filename: 'dashboard-traffic-analytics-disabled-empty-state');
+});
diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php
index af409a3c46..655aec85fe 100644
--- a/tests/v4/Feature/DangerDeleteResourceTest.php
+++ b/tests/v4/Feature/DangerDeleteResourceTest.php
@@ -5,6 +5,7 @@ use App\Livewire\Project\Shared\Danger;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
+use App\Models\OauthIdentity;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
@@ -87,6 +88,21 @@ test('delete redirects before dispatching resource cleanup after the response',
Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job) => $job->resource->is($service));
});
+test('delete succeeds without password for an oauth user', function () {
+ OauthIdentity::create([
+ 'user_id' => $this->user->id,
+ 'provider' => 'oidc',
+ 'issuer' => 'https://idp.example.com',
+ 'provider_user_id' => 'oauth-user-id',
+ ]);
+
+ Livewire::test(Danger::class, ['resource' => $this->application])
+ ->call('delete', '')
+ ->assertHasNoErrors();
+
+ expect(Application::find($this->application->id))->toBeNull();
+});
+
test('delete applies selectedActions from checkbox state', function () {
$component = Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']);