From d8d316b5f8c74e55ac3726c70067e947bac9d0d5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:17:48 +0200 Subject: [PATCH 01/24] feat(search): implement global search functionality with caching and modal interface --- app/Livewire/GlobalSearch.php | 371 ++++++++++++++++++ app/Models/Application.php | 123 +++--- app/Models/Server.php | 3 +- app/Models/Service.php | 3 +- app/Models/StandaloneClickhouse.php | 8 +- app/Models/StandaloneDragonfly.php | 8 +- app/Models/StandaloneKeydb.php | 8 +- app/Models/StandaloneMariadb.php | 8 +- app/Models/StandaloneMongodb.php | 8 +- app/Models/StandaloneMysql.php | 8 +- app/Models/StandalonePostgresql.php | 8 +- app/Models/StandaloneRedis.php | 8 +- app/Traits/ClearsGlobalSearchCache.php | 53 +++ resources/views/components/navbar.blade.php | 27 +- .../views/livewire/global-search.blade.php | 236 +++++++++++ 15 files changed, 797 insertions(+), 83 deletions(-) create mode 100644 app/Livewire/GlobalSearch.php create mode 100644 app/Traits/ClearsGlobalSearchCache.php create mode 100644 resources/views/livewire/global-search.blade.php diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php new file mode 100644 index 000000000..3b3075fc9 --- /dev/null +++ b/app/Livewire/GlobalSearch.php @@ -0,0 +1,371 @@ +searchQuery = ''; + $this->isModalOpen = false; + $this->searchResults = []; + $this->allSearchableItems = []; + } + + public function openSearchModal() + { + $this->isModalOpen = true; + $this->loadSearchableItems(); + $this->dispatch('search-modal-opened'); + } + + public function closeSearchModal() + { + $this->isModalOpen = false; + $this->searchQuery = ''; + $this->searchResults = []; + } + + public static function getCacheKey($teamId) + { + return 'global_search_items_'.$teamId; + } + + public static function clearTeamCache($teamId) + { + Cache::forget(self::getCacheKey($teamId)); + } + + public function updatedSearchQuery() + { + $this->search(); + } + + private function loadSearchableItems() + { + // Try to get from Redis cache first + $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); + + $this->allSearchableItems = Cache::remember($cacheKey, 300, function () { + $items = collect(); + $team = auth()->user()->currentTeam(); + + // Get all applications + $applications = Application::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($app) { + // Collect all FQDNs from the application + $fqdns = collect([]); + + // For regular applications + if ($app->fqdn) { + $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + } + + // For docker compose based applications + if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) { + try { + $composeDomains = json_decode($app->docker_compose_domains, true); + if (is_array($composeDomains)) { + foreach ($composeDomains as $serviceName => $domains) { + if (is_array($domains)) { + $fqdns = $fqdns->merge($domains); + } + } + } + } catch (\Exception $e) { + // Ignore JSON parsing errors + } + } + + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $app->id, + 'name' => $app->name, + 'type' => 'application', + 'uuid' => $app->uuid, + 'description' => $app->description, + 'link' => $app->link(), + 'project' => $app->environment->project->name ?? null, + 'environment' => $app->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString), + ]; + }); + + // Get all services + $services = Service::ownedByCurrentTeam() + ->with(['environment.project', 'applications']) + ->get() + ->map(function ($service) { + // Collect all FQDNs from service applications + $fqdns = collect([]); + foreach ($service->applications as $app) { + if ($app->fqdn) { + $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + $fqdns = $fqdns->merge($appFqdns); + } + } + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $service->id, + 'name' => $service->name, + 'type' => 'service', + 'uuid' => $service->uuid, + 'description' => $service->description, + 'link' => $service->link(), + 'project' => $service->environment->project->name ?? null, + 'environment' => $service->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString), + ]; + }); + + // Get all standalone databases + $databases = collect(); + + // PostgreSQL + $databases = $databases->merge( + StandalonePostgresql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'postgresql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' postgresql '.$db->description), + ]; + }) + ); + + // MySQL + $databases = $databases->merge( + StandaloneMysql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mysql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mysql '.$db->description), + ]; + }) + ); + + // MariaDB + $databases = $databases->merge( + StandaloneMariadb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mariadb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mariadb '.$db->description), + ]; + }) + ); + + // MongoDB + $databases = $databases->merge( + StandaloneMongodb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mongodb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mongodb '.$db->description), + ]; + }) + ); + + // Redis + $databases = $databases->merge( + StandaloneRedis::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'redis', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' redis '.$db->description), + ]; + }) + ); + + // KeyDB + $databases = $databases->merge( + StandaloneKeydb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'keydb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' keydb '.$db->description), + ]; + }) + ); + + // Dragonfly + $databases = $databases->merge( + StandaloneDragonfly::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'dragonfly', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' dragonfly '.$db->description), + ]; + }) + ); + + // Clickhouse + $databases = $databases->merge( + StandaloneClickhouse::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'clickhouse', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' clickhouse '.$db->description), + ]; + }) + ); + + // Get all servers + $servers = Server::ownedByCurrentTeam() + ->get() + ->map(function ($server) { + return [ + 'id' => $server->id, + 'name' => $server->name, + 'type' => 'server', + 'uuid' => $server->uuid, + 'description' => $server->description, + 'link' => $server->url(), + 'project' => null, + 'environment' => null, + 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description), + ]; + }); + + // Merge all collections + $items = $items->merge($applications) + ->merge($services) + ->merge($databases) + ->merge($servers); + + return $items->toArray(); + }); + } + + private function search() + { + if (strlen($this->searchQuery) < 2) { + $this->searchResults = []; + + return; + } + + $query = strtolower($this->searchQuery); + + // Case-insensitive search in the items + $this->searchResults = collect($this->allSearchableItems) + ->filter(function ($item) use ($query) { + return str_contains($item['search_text'], $query); + }) + ->take(20) + ->values() + ->toArray(); + } + + public function render() + { + return view('livewire.global-search'); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index 07df53687..094e5c82b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; use App\Services\ConfigurationGenerator; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -110,7 +111,7 @@ use Visus\Cuid2\Cuid2; class Application extends BaseModel { - use HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; @@ -123,66 +124,6 @@ class Application extends BaseModel 'http_basic_auth_password' => 'encrypted', ]; - public function customNetworkAliases(): Attribute - { - return Attribute::make( - set: function ($value) { - if (is_null($value) || $value === '') { - return null; - } - - // If it's already a JSON string, decode it - if (is_string($value) && $this->isJson($value)) { - $value = json_decode($value, true); - } - - // If it's a string but not JSON, treat it as a comma-separated list - if (is_string($value) && ! is_array($value)) { - $value = explode(',', $value); - } - - $value = collect($value) - ->map(function ($alias) { - if (is_string($alias)) { - return str_replace(' ', '-', trim($alias)); - } - - return null; - }) - ->filter() - ->unique() // Remove duplicate values - ->values() - ->toArray(); - - return empty($value) ? null : json_encode($value); - }, - get: function ($value) { - if (is_null($value)) { - return null; - } - - if (is_string($value) && $this->isJson($value)) { - return json_decode($value, true); - } - - return is_array($value) ? $value : []; - } - ); - } - - /** - * Check if a string is a valid JSON - */ - private function isJson($string) - { - if (! is_string($string)) { - return false; - } - json_decode($string); - - return json_last_error() === JSON_ERROR_NONE; - } - protected static function booted() { static::addGlobalScope('withRelations', function ($builder) { @@ -250,6 +191,66 @@ class Application extends BaseModel }); } + public function customNetworkAliases(): Attribute + { + return Attribute::make( + set: function ($value) { + if (is_null($value) || $value === '') { + return null; + } + + // If it's already a JSON string, decode it + if (is_string($value) && $this->isJson($value)) { + $value = json_decode($value, true); + } + + // If it's a string but not JSON, treat it as a comma-separated list + if (is_string($value) && ! is_array($value)) { + $value = explode(',', $value); + } + + $value = collect($value) + ->map(function ($alias) { + if (is_string($alias)) { + return str_replace(' ', '-', trim($alias)); + } + + return null; + }) + ->filter() + ->unique() // Remove duplicate values + ->values() + ->toArray(); + + return empty($value) ? null : json_encode($value); + }, + get: function ($value) { + if (is_null($value)) { + return null; + } + + if (is_string($value) && $this->isJson($value)) { + return json_decode($value, true); + } + + return is_array($value) ? $value : []; + } + ); + } + + /** + * Check if a string is a valid JSON + */ + private function isJson($string) + { + if (! is_string($string)) { + return false; + } + json_decode($string); + + return json_last_error() === JSON_ERROR_NONE; + } + public static function ownedByCurrentTeamAPI(int $teamId) { return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); diff --git a/app/Models/Server.php b/app/Models/Server.php index cc5315c6f..829a4b5aa 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -13,6 +13,7 @@ use App\Jobs\RegenerateSslCertJob; use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -55,7 +56,7 @@ use Visus\Cuid2\Cuid2; class Server extends BaseModel { - use HasFactory, SchemalessAttributesTrait, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes; public static $batch_counter = 0; diff --git a/app/Models/Service.php b/app/Models/Service.php index dd8d0ac7e..d42d471c6 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ProcessStatus; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -41,7 +42,7 @@ use Visus\Cuid2\Cuid2; )] class Service extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 87c5c3422..146ee0a2d 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -43,6 +44,11 @@ class StandaloneClickhouse extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 118c72726..90e7304f1 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -43,6 +44,11 @@ class StandaloneDragonfly extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 9d674b6c2..ad0cabf7e 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -43,6 +44,11 @@ class StandaloneKeydb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 616d536c1..3d9e38147 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -44,6 +45,11 @@ class StandaloneMariadb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index b26b6c967..7cccd332a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -46,6 +47,11 @@ class StandaloneMongodb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 7b6f1b94e..80269972f 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -44,6 +45,11 @@ class StandaloneMysql extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index f13e6ffab..acde7a20c 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -44,6 +45,11 @@ class StandalonePostgresql extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + public function workdir() { return database_configuration_dir()."/{$this->uuid}"; diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 9f7c96a08..001ebe36a 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -45,6 +46,11 @@ class StandaloneRedis extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php new file mode 100644 index 000000000..fe6cbaa38 --- /dev/null +++ b/app/Traits/ClearsGlobalSearchCache.php @@ -0,0 +1,53 @@ +getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + }); + + static::created(function ($model) { + // Clear search cache when model is created + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + }); + + static::deleted(function ($model) { + // Clear search cache when model is deleted + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + }); + } + + private function getTeamIdForCache() + { + // For database models, team is accessed through environment.project.team + if (method_exists($this, 'team')) { + $team = $this->team(); + if (filled($team)) { + return is_object($team) ? $team->id : null; + } + } + + // For models with direct team_id property + if (property_exists($this, 'team_id') || isset($this->team_id)) { + return $this->team_id; + } + + return null; + } +} diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index f61ea681e..1c5987e82 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -59,20 +59,20 @@ if (this.zoom === '90') { const style = document.createElement('style'); style.textContent = ` - html { - font-size: 93.75%; - } - - :root { - --vh: 1vh; - } - - @media (min-width: 1024px) { html { - font-size: 87.5%; + font-size: 93.75%; } - } - `; + + :root { + --vh: 1vh; + } + + @media (min-width: 1024px) { + html { + font-size: 87.5%; + } + } + `; document.head.appendChild(style); } } @@ -82,6 +82,9 @@
Coolify
+
+ +
diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php new file mode 100644 index 000000000..0792dadfb --- /dev/null +++ b/resources/views/livewire/global-search.blade.php @@ -0,0 +1,236 @@ +
+ +
+ +
+ + + +
From 575793709bfb256e922a1a58158dbc18fa46b974 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:22:24 +0200 Subject: [PATCH 02/24] feat(search): enable query logging for global search caching --- app/Livewire/GlobalSearch.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 3b3075fc9..dacc0d4db 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -69,6 +69,7 @@ class GlobalSearch extends Component $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); $this->allSearchableItems = Cache::remember($cacheKey, 300, function () { + ray()->showQueries(); $items = collect(); $team = auth()->user()->currentTeam(); From f2236236039f966b856d9833f14dbf621d7e7a24 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:22:31 +0200 Subject: [PATCH 03/24] refactor(search): optimize cache clearing logic to only trigger on searchable field changes --- app/Traits/ClearsGlobalSearchCache.php | 42 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php index fe6cbaa38..0bcc5d319 100644 --- a/app/Traits/ClearsGlobalSearchCache.php +++ b/app/Traits/ClearsGlobalSearchCache.php @@ -8,16 +8,18 @@ trait ClearsGlobalSearchCache { protected static function bootClearsGlobalSearchCache() { - static::saved(function ($model) { - // Clear search cache when model is saved - $teamId = $model->getTeamIdForCache(); - if (filled($teamId)) { - GlobalSearch::clearTeamCache($teamId); + static::saving(function ($model) { + // Only clear cache if searchable fields are being changed + if ($model->hasSearchableChanges()) { + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } } }); static::created(function ($model) { - // Clear search cache when model is created + // Always clear cache when model is created $teamId = $model->getTeamIdForCache(); if (filled($teamId)) { GlobalSearch::clearTeamCache($teamId); @@ -25,7 +27,7 @@ trait ClearsGlobalSearchCache }); static::deleted(function ($model) { - // Clear search cache when model is deleted + // Always clear cache when model is deleted $teamId = $model->getTeamIdForCache(); if (filled($teamId)) { GlobalSearch::clearTeamCache($teamId); @@ -33,6 +35,32 @@ trait ClearsGlobalSearchCache }); } + private function hasSearchableChanges(): bool + { + // Define searchable fields based on model type + $searchableFields = ['name', 'description']; + + // Add model-specific searchable fields + if ($this instanceof \App\Models\Application) { + $searchableFields[] = 'fqdn'; + $searchableFields[] = 'docker_compose_domains'; + } elseif ($this instanceof \App\Models\Server) { + $searchableFields[] = 'ip'; + } elseif ($this instanceof \App\Models\Service) { + // Services don't have direct fqdn, but name and description are covered + } + // Database models only have name and description as searchable + + // Check if any searchable field is dirty + foreach ($searchableFields as $field) { + if ($this->isDirty($field)) { + return true; + } + } + + return false; + } + private function getTeamIdForCache() { // For database models, team is accessed through environment.project.team From 65f24de101b4764505662534ec83ded4bc40d57e Mon Sep 17 00:00:00 2001 From: ShadowArcanist Date: Fri, 19 Sep 2025 16:26:11 +0530 Subject: [PATCH 04/24] Changed Sentinel metrics color from yellow to blue + cyan (cpu + memory) --- resources/views/layouts/base.blade.php | 9 ++- .../livewire/project/shared/metrics.blade.php | 74 ++++++++++--------- .../views/livewire/server/charts.blade.php | 60 +++++++-------- 3 files changed, 75 insertions(+), 68 deletions(-) diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index ebb134324..c074412d3 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -138,7 +138,8 @@ } } let theme = localStorage.theme - let baseColor = '#FCD452' + let cpuColor = '#1e90ff' + let ramColor = '#00ced1' let textColor = '#ffffff' let editorBackground = '#181818' let editorTheme = 'blackboard' @@ -149,12 +150,14 @@ theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } if (theme == 'dark') { - baseColor = '#FCD452' + cpuColor = '#1e90ff' + ramColor = '#00ced1' textColor = '#ffffff' editorBackground = '#181818' editorTheme = 'blackboard' } else { - baseColor = 'black' + cpuColor = '#1e90ff' + ramColor = '#00ced1' textColor = '#000000' editorBackground = '#ffffff' editorTheme = null diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index cfe83ded6..d6609d9e6 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -34,6 +34,7 @@ const optionsServerCpu = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -68,16 +69,16 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - }, - series: [{ - name: "CPU %", + grid: { + show: true, + borderColor: '', + }, + colors: [cpuColor], + xaxis: { + type: 'datetime', + }, + series: [{ + name: "CPU %", data: [] }], noData: { @@ -101,11 +102,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { checkTheme(); - serverCpuChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverCpuChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [cpuColor], xaxis: { type: 'datetime', labels: { @@ -143,6 +144,7 @@ const optionsServerMemory = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -177,22 +179,22 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - series: [{ - name: "Memory (MB)", + grid: { + show: true, + borderColor: '', + }, + colors: [ramColor], + xaxis: { + type: 'datetime', + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + series: [{ + name: "Memory (MB)", data: [] }], noData: { @@ -217,11 +219,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { checkTheme(); - serverMemoryChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverMemoryChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [ramColor], xaxis: { type: 'datetime', labels: { diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index b84e0240f..f5a2418fd 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -27,6 +27,7 @@ const optionsServerCpu = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -61,16 +62,16 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - }, - series: [{ - name: 'CPU %', + grid: { + show: true, + borderColor: '', + }, + colors: [cpuColor], + xaxis: { + type: 'datetime', + }, + series: [{ + name: 'CPU %', data: [] }], noData: { @@ -95,11 +96,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { checkTheme(); - serverCpuChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverCpuChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [cpuColor], xaxis: { type: 'datetime', labels: { @@ -138,6 +139,7 @@ const optionsServerMemory = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -172,15 +174,15 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - labels: { - show: true, + grid: { + show: true, + borderColor: '', + }, + colors: [ramColor], + xaxis: { + type: 'datetime', + labels: { + show: true, style: { colors: textColor, } @@ -212,11 +214,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { checkTheme(); - serverMemoryChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverMemoryChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [ramColor], xaxis: { type: 'datetime', labels: { From bfaefed1aea4864eb30e6c813a919279bae4e785 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:45:37 +0200 Subject: [PATCH 05/24] refactor(environment): streamline rendering of Docker Build Secrets checkbox and adjust layout for environment variable settings --- .../shared/environment-variable/all.blade.php | 24 ++++++------- .../environment-variable/show.blade.php | 34 +++++++++---------- 2 files changed, 27 insertions(+), 31 deletions(-) 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 6854ffaa4..cee6b291d 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -28,19 +28,17 @@ @endcan
@endif - @if (data_get($resource, 'build_pack') !== 'dockercompose') -
- @can('manageEnvironment', $resource) - - @else - - @endcan -
- @endif +
+ @can('manageEnvironment', $resource) + + @else + + @endcan +
@endif @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 6598b66ff..953bc59fa 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -78,22 +78,20 @@ @if ($isSharedVariable) @else - @if (!$env->is_coolify) - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - - @endif + @if (!$env->is_nixpacks) + + @endif + + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + @endif @endif @endif @@ -129,8 +127,8 @@ @if (!$is_redis_credential) @if ($type === 'service') + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> From 593c1b476743b0129d7a346c8232d835ecb18600 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:46:00 +0200 Subject: [PATCH 06/24] fix(deployment): enhance Dockerfile modification for build-time variables and secrets during deployment in case of docker compose buildpack --- app/Jobs/ApplicationDeploymentJob.php | 172 +++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index ae89649af..c880057e5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -606,6 +606,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"), 'hidden' => true, ]); + + // Modify Dockerfiles for ARGs and build secrets + $this->modify_dockerfiles_for_compose($composeFile); // Build new container to limit downtime. $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); @@ -632,6 +635,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull"; } + + if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { + $build_args_string = $this->build_args->implode(' '); + $command .= " {$build_args_string}"; + $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.'); + } + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], ); @@ -2830,8 +2840,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Get environment variables for secrets $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); if ($variables->isEmpty()) { return; @@ -2868,6 +2878,164 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } + private function modify_dockerfiles_for_compose($composeFile) + { + if ($this->application->build_pack !== 'dockercompose') { + return; + } + + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get() + : $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + if ($variables->isEmpty()) { + $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); + + return; + } + + $services = data_get($composeFile, 'services', []); + + foreach ($services as $serviceName => $service) { + if (! isset($service['build'])) { + continue; + } + + $context = '.'; + $dockerfile = 'Dockerfile'; + + if (is_string($service['build'])) { + $context = $service['build']; + } elseif (is_array($service['build'])) { + $context = data_get($service['build'], 'context', '.'); + $dockerfile = data_get($service['build'], 'dockerfile', 'Dockerfile'); + } + + $dockerfilePath = rtrim($context, '/').'/'.ltrim($dockerfile, '/'); + if (str_starts_with($dockerfilePath, './')) { + $dockerfilePath = substr($dockerfilePath, 2); + } + if (str_starts_with($dockerfilePath, '/')) { + $dockerfilePath = substr($dockerfilePath, 1); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/{$dockerfilePath} && echo 'exists' || echo 'not found'"), + 'hidden' => true, + 'save' => 'dockerfile_check_'.$serviceName, + ]); + + if (str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString() !== 'exists') { + $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection."); + + continue; + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/{$dockerfilePath}"), + 'hidden' => true, + 'save' => 'dockerfile_content_'.$serviceName, + ]); + + $dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName); + if (! $dockerfileContent) { + continue; + } + + $dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n")); + + $fromIndices = []; + $dockerfile_lines->each(function ($line, $index) use (&$fromIndices) { + if (str($line)->trim()->startsWith('FROM')) { + $fromIndices[] = $index; + } + }); + + if (empty($fromIndices)) { + $this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping."); + + continue; + } + + $isMultiStage = count($fromIndices) > 1; + + $argsToAdd = collect([]); + foreach ($variables as $env) { + $argsToAdd->push("ARG {$env->key}"); + } + + ray($argsToAdd); + if ($argsToAdd->isEmpty()) { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); + + continue; + } + + $totalAdded = 0; + $offset = 0; + + foreach ($fromIndices as $stageIndex => $fromIndex) { + $adjustedIndex = $fromIndex + $offset; + + $stageStart = $adjustedIndex + 1; + $stageEnd = isset($fromIndices[$stageIndex + 1]) + ? $fromIndices[$stageIndex + 1] + $offset + : $dockerfile_lines->count(); + + $existingStageArgs = collect([]); + for ($i = $stageStart; $i < $stageEnd; $i++) { + $line = $dockerfile_lines->get($i); + if (! $line || ! str($line)->trim()->startsWith('ARG')) { + break; + } + $parts = explode(' ', trim($line), 2); + if (count($parts) >= 2) { + $argPart = $parts[1]; + $keyValue = explode('=', $argPart, 2); + $existingStageArgs->push($keyValue[0]); + } + } + + $stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) { + $key = str($arg)->after('ARG ')->trim()->toString(); + + return ! $existingStageArgs->contains($key); + }); + + if ($stageArgsToAdd->isNotEmpty()) { + $dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray()); + $totalAdded += $stageArgsToAdd->count(); + $offset += $stageArgsToAdd->count(); + } + } + + if ($totalAdded > 0) { + $dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}/{$dockerfilePath} > /dev/null"), + 'hidden' => true, + ]); + + $stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : ''; + $this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}."); + } else { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist."); + } + + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}"; + $this->modify_dockerfile_for_secrets($fullDockerfilePath); + $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets."); + } + } + } + private function add_build_secrets_to_compose($composeFile) { // Get environment variables for secrets From 99fd4b424d186c6557c3f48aa43708935c827bef Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:17:10 +0200 Subject: [PATCH 07/24] feat(environment): add dynamic checkbox options for environment variable settings based on user permissions and variable types --- .../environment-variable/show.blade.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 953bc59fa..a04b477d5 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -21,6 +21,95 @@ step2ButtonText="Permanently Delete" /> @endcan + @can('update', $this->env) +
+
+ @if (!$is_redis_credential) + @if ($type === 'service') + + + + + @else + @if ($is_shared) + + @else + @if ($isSharedVariable) + + @else + @if (!$env->is_nixpacks) + + @endif + + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif + @endif + @endif + @endif + @endif + @endif +
+
+ @else +
+
+ @if (!$is_redis_credential) + @if ($type === 'service') + + + + + @else + @if ($is_shared) + + @else + @if ($isSharedVariable) + + @else + + + + @if ($is_multiline === false) + + @endif + @endif + @endif + @endif + @endif +
+
+ @endcan @else @can('update', $this->env) @if ($isDisabled) From 3f48dcb5750011c4ab6db724988e170c1b2bb314 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:54:44 +0200 Subject: [PATCH 08/24] feat(redaction): implement sensitive information redaction in logs and commands --- app/Models/ApplicationDeploymentQueue.php | 43 +++++++++++++++++++- app/Traits/ExecuteRemoteCommand.php | 48 +++++++++++++++++++++-- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 2a9bea67a..8df6877ab 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -85,6 +85,47 @@ class ApplicationDeploymentQueue extends Model return str($this->commit_message)->value(); } + private function redactSensitiveInfo($text) + { + $text = remove_iip($text); + + $app = $this->application; + if (! $app) { + return $text; + } + + $lockedVars = collect([]); + + if ($app->environment_variables) { + $lockedVars = $lockedVars->merge( + $app->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if ($this->pull_request_id !== 0 && $app->environment_variables_preview) { + $lockedVars = $lockedVars->merge( + $app->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) { if ($type === 'error') { @@ -96,7 +137,7 @@ class ApplicationDeploymentQueue extends Model } $newLogEntry = [ 'command' => null, - 'output' => remove_iip($message), + 'output' => $this->redactSensitiveInfo($message), 'type' => $type, 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 0c3414efe..f9df19c16 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -17,6 +17,46 @@ trait ExecuteRemoteCommand public static int $batch_counter = 0; + private function redact_sensitive_info($text) + { + $text = remove_iip($text); + + if (! isset($this->application)) { + return $text; + } + + $lockedVars = collect([]); + + if (isset($this->application->environment_variables)) { + $lockedVars = $lockedVars->merge( + $this->application->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) { + $lockedVars = $lockedVars->merge( + $this->application->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function execute_remote_command(...$commands) { static::$batch_counter++; @@ -74,7 +114,7 @@ trait ExecuteRemoteCommand // Track SSH retry event in Sentry $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [ 'server' => $this->server->name ?? $this->server->ip ?? 'unknown', - 'command' => remove_iip($command), + 'command' => $this->redact_sensitive_info($command), 'trait' => 'ExecuteRemoteCommand', ]); @@ -125,8 +165,8 @@ trait ExecuteRemoteCommand $sanitized_output = sanitize_utf8_text($output); $new_log_entry = [ - 'command' => remove_iip($command), - 'output' => remove_iip($sanitized_output), + 'command' => $this->redact_sensitive_info($command), + 'output' => $this->redact_sensitive_info($sanitized_output), 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout', 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, @@ -194,7 +234,7 @@ trait ExecuteRemoteCommand $retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}"; $new_log_entry = [ - 'output' => remove_iip($retryMessage), + 'output' => $this->redact_sensitive_info($retryMessage), 'type' => 'stdout', 'timestamp' => Carbon::now('UTC'), 'hidden' => false, From 0ef0247e14ac0f5a808b9a21600070fe0dc3917f Mon Sep 17 00:00:00 2001 From: ShadowArcanist Date: Fri, 19 Sep 2025 22:40:08 +0530 Subject: [PATCH 09/24] Improved metrics graph tooltip to show usage in a better way and added timestamp to the tooltip --- app/Livewire/Project/Shared/Metrics.php | 12 +- resources/css/utilities.css | 17 ++ .../livewire/project/shared/metrics.blade.php | 277 ++++++++++-------- .../views/livewire/server/charts.blade.php | 54 +++- 4 files changed, 222 insertions(+), 138 deletions(-) diff --git a/app/Livewire/Project/Shared/Metrics.php b/app/Livewire/Project/Shared/Metrics.php index fdc35fc0f..9dc944f9d 100644 --- a/app/Livewire/Project/Shared/Metrics.php +++ b/app/Livewire/Project/Shared/Metrics.php @@ -8,7 +8,7 @@ class Metrics extends Component { public $resource; - public $chartId = 'container-cpu'; + public $chartId = 'metrics'; public $data; @@ -33,6 +33,16 @@ class Metrics extends Component try { $cpuMetrics = $this->resource->getCpuMetrics($this->interval); $memoryMetrics = $this->resource->getMemoryMetrics($this->interval); + + // Debug logging + \Log::info('Metrics loadData called', [ + 'chartId' => $this->chartId, + 'cpuMetrics' => $cpuMetrics, + 'memoryMetrics' => $memoryMetrics, + 'cpuEvent' => "refreshChartData-{$this->chartId}-cpu", + 'memoryEvent' => "refreshChartData-{$this->chartId}-memory" + ]); + $this->dispatch("refreshChartData-{$this->chartId}-cpu", [ 'seriesData' => $cpuMetrics, ]); diff --git a/resources/css/utilities.css b/resources/css/utilities.css index d09d7f49c..65869e02f 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -10,6 +10,23 @@ @apply hidden!; } +@utility apexcharts-tooltip-custom { + @apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm; + min-width: 160px; +} + +@utility apexcharts-tooltip-custom-value { + @apply text-neutral-700 dark:text-neutral-300 mb-1; +} + +@utility apexcharts-tooltip-value-bold { + @apply font-bold text-black dark:text-white; +} + +@utility apexcharts-tooltip-custom-title { + @apply text-xs text-neutral-500 dark:text-neutral-400 font-medium; +} + @utility input-sticky { @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300; } diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index d6609d9e6..9b08babb3 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -1,21 +1,20 @@
-
+

Metrics

Basic metrics for your container.
- @if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose') -
Metrics are not available for Docker Compose applications yet!
- @elseif(!$resource->destination->server->isMetricsEnabled()) -
Metrics are only available for servers with Sentinel & Metrics enabled!
-
Go to Server settings to - enable - it.
- @else - @if (!str($resource->status)->contains('running')) -
Metrics are only available when this resource is running!
+
+ @if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose') +
Metrics are not available for Docker Compose applications yet!
+ @elseif(!$resource->destination->server->isMetricsEnabled()) +
Metrics are only available for servers with Sentinel & Metrics enabled!
+
Go to Server settings to enable it.
@else - + @if (!str($resource->status)->contains('running')) +
Metrics are only available when this resource is running!
+ @else +
+ @@ -77,63 +76,76 @@ xaxis: { type: 'datetime', }, - series: [{ - name: "CPU %", - data: [] - }], - noData: { - text: 'Loading...', - style: { - color: textColor, - } - }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, - legend: { - show: false - } + series: [{ + name: "CPU %", + data: [] + }], + noData: { + text: 'Loading...', + style: { + color: textColor, + } + }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '
' + + '
CPU: ' + value + '%
' + + '
' + timeString + '
' + + '
'; + } + }, + legend: { + show: false + } } - const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu); - serverCpuChart.render(); - document.addEventListener('livewire:init', () => { - Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { - checkTheme(); - serverCpuChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [cpuColor], - xaxis: { - type: 'datetime', - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - yaxis: { - show: true, - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - noData: { - text: 'Loading...', - style: { - color: textColor, - } - } - }); - }); - }); + const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu); + serverCpuChart.render(); + Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { + checkTheme(); + serverCpuChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [cpuColor], + xaxis: { + type: 'datetime', + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + yaxis: { + show: true, + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + noData: { + text: 'Loading...', + style: { + color: textColor, + } + } + }); + });

Memory (MB)

@@ -195,65 +207,80 @@ }, series: [{ name: "Memory (MB)", - data: [] - }], - noData: { - text: 'Loading...', - style: { - color: textColor, - } - }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, - legend: { - show: false - } + data: [] + }], + noData: { + text: 'Loading...', + style: { + color: textColor, + } + }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '
' + + '
Memory: ' + value + ' MB
' + + '
' + timeString + '
' + + '
'; + } + }, + legend: { + show: false + } } - const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`), - optionsServerMemory); - serverMemoryChart.render(); - document.addEventListener('livewire:init', () => { - Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { - checkTheme(); - serverMemoryChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [ramColor], - xaxis: { - type: 'datetime', - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - yaxis: { - min: 0, - show: true, - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - noData: { - text: 'Loading...', - style: { - color: textColor, - } - } - }); - }); - }); + const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`), + optionsServerMemory); + serverMemoryChart.render(); + Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { + checkTheme(); + serverMemoryChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [ramColor], + xaxis: { + type: 'datetime', + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + yaxis: { + min: 0, + show: true, + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + noData: { + text: 'Loading...', + style: { + color: textColor, + } + } + }); + });
+
@endif @endif +
diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index f5a2418fd..2cb8e2c37 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -80,12 +80,27 @@ color: textColor, } }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '
' + + '
CPU: ' + value + '%
' + + '
' + timeString + '
' + + '
'; + } + }, legend: { show: false } @@ -198,12 +213,27 @@ color: textColor, } }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '
' + + '
Memory: ' + value + '%
' + + '
' + timeString + '
' + + '
'; + } + }, legend: { show: false } From 610ef310341d7bc7384349f80f787b0b9ce3c41e Mon Sep 17 00:00:00 2001 From: ShadowArcanist Date: Fri, 19 Sep 2025 22:51:24 +0530 Subject: [PATCH 10/24] Hidden metrics charts grid borders on darkmode (it was too bright on darkmode) --- resources/css/utilities.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 65869e02f..694ad61a3 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -6,6 +6,10 @@ @apply hidden!; } +@utility apexcharts-grid-borders { + @apply dark:hidden!; +} + @utility apexcharts-xaxistooltip { @apply hidden!; } From a0f4566580eb982705f5ceba3efff8faddddce16 Mon Sep 17 00:00:00 2001 From: ShadowArcanist Date: Fri, 19 Sep 2025 22:55:25 +0530 Subject: [PATCH 11/24] Fixed Memory title on app metrics being larger than CPU title --- resources/views/livewire/project/shared/metrics.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index 9b08babb3..84e4595aa 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -148,7 +148,7 @@ }); -

Memory (MB)

+

Memory (MB)

-

Memory (MB)

+

Memory Usage

-

Memory (%)

+

Memory Usage