feat(search): implement global search functionality with caching and modal interface

This commit is contained in:
Andras Bacsai
2025-09-19 10:17:48 +02:00
parent b64de1b5cd
commit d8d316b5f8
15 changed files with 797 additions and 83 deletions

View File

@@ -0,0 +1,371 @@
<?php
namespace App\Livewire;
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
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 Illuminate\Support\Facades\Cache;
use Livewire\Component;
class GlobalSearch extends Component
{
public $searchQuery = '';
public $isModalOpen = false;
public $searchResults = [];
public $allSearchableItems = [];
public function mount()
{
$this->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');
}
}

View File

@@ -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');

View File

@@ -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;

View File

@@ -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';

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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}";

View File

@@ -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(

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Traits;
use App\Livewire\GlobalSearch;
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::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;
}
}