mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 06:23:23 +00:00
Improve project resource UIs: sort domains by DNS failure, stop re-adding www pairs on refresh, lazy-load storage tabs with counts, tighten env-var tables, keep application tabs active across Livewire polls, unify database type labels, and update related CSS/JS and tests.
392 lines
13 KiB
PHP
392 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Project\Service;
|
|
|
|
use App\Models\Application;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Support\ValidationPatterns;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Livewire\Component;
|
|
|
|
class Storage extends Component
|
|
{
|
|
use AuthorizesRequests;
|
|
|
|
public $resource;
|
|
|
|
public $fileStorage;
|
|
|
|
public $isSwarm = false;
|
|
|
|
public string $name = '';
|
|
|
|
public string $mount_path = '';
|
|
|
|
public ?string $host_path = null;
|
|
|
|
public string $file_storage_path = '';
|
|
|
|
public ?string $file_storage_content = null;
|
|
|
|
public string $host_file_storage_source = '';
|
|
|
|
public string $host_file_storage_destination = '';
|
|
|
|
public string $file_storage_directory_source = '';
|
|
|
|
public string $file_storage_directory_destination = '';
|
|
|
|
public string $activeTab = 'volumes';
|
|
|
|
public int $cachedVolumeCount = 0;
|
|
|
|
public int $cachedFileCount = 0;
|
|
|
|
public int $cachedDirectoryCount = 0;
|
|
|
|
public function getListeners()
|
|
{
|
|
$teamId = auth()->user()->currentTeam()->id;
|
|
|
|
return [
|
|
"echo-private:team.{$teamId},FileStorageChanged" => 'refreshStoragesFromEvent',
|
|
'refreshStorages',
|
|
'addNewVolume',
|
|
];
|
|
}
|
|
|
|
public function mount()
|
|
{
|
|
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
|
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
|
} else {
|
|
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
|
}
|
|
|
|
if ($this->resource->getMorphClass() === Application::class) {
|
|
$this->resource->loadMissing('destination.server', 'environment.project');
|
|
if ($this->resource->destination?->server?->isSwarm()) {
|
|
$this->isSwarm = true;
|
|
}
|
|
}
|
|
|
|
// Counts only on mount — child All (volumes) / file list load their own payloads.
|
|
$this->loadVolumeCount();
|
|
$this->loadFileStorageMetaCounts();
|
|
$this->activeTab = $this->resolveDefaultTab();
|
|
$this->fileStorage = collect();
|
|
$this->loadFileStorageForActiveTab();
|
|
}
|
|
|
|
public function refreshStoragesFromEvent()
|
|
{
|
|
$this->refreshStorages();
|
|
$this->dispatch('warning', 'File storage changed. Usually it means that the file / directory is already defined on the server, so Coolify set it up for you properly on the UI.');
|
|
}
|
|
|
|
public function refreshStorages()
|
|
{
|
|
// Avoid loading full volume models onto this parent (child All owns that snapshot).
|
|
$this->resource->unsetRelation('persistentStorages');
|
|
$this->loadVolumeCount();
|
|
$this->loadFileStorageMetaCounts();
|
|
$this->loadFileStorageForActiveTab();
|
|
}
|
|
|
|
public function setActiveTab(string $tab): void
|
|
{
|
|
if (! in_array($tab, ['volumes', 'files', 'directories'], true)) {
|
|
return;
|
|
}
|
|
|
|
$this->activeTab = $tab;
|
|
$this->loadFileStorageForActiveTab();
|
|
}
|
|
|
|
private function resolveDefaultTab(): string
|
|
{
|
|
if ($this->volumeCount > 0) {
|
|
return 'volumes';
|
|
}
|
|
|
|
if ($this->fileCount > 0) {
|
|
return 'files';
|
|
}
|
|
|
|
if ($this->directoryCount > 0) {
|
|
return 'directories';
|
|
}
|
|
|
|
return 'volumes';
|
|
}
|
|
|
|
private function loadVolumeCount(): void
|
|
{
|
|
$this->cachedVolumeCount = $this->resource->persistentStorages()->count();
|
|
}
|
|
|
|
/**
|
|
* Counts only — avoids loading file contents into the Livewire snapshot on the volumes tab.
|
|
*/
|
|
private function loadFileStorageMetaCounts(): void
|
|
{
|
|
$this->cachedFileCount = $this->resource->fileStorages()->where('is_directory', false)->count();
|
|
$this->cachedDirectoryCount = $this->resource->fileStorages()->where('is_directory', true)->count();
|
|
}
|
|
|
|
/**
|
|
* Load full file/directory mounts only for the active tab (content only on files).
|
|
*/
|
|
private function loadFileStorageForActiveTab(): void
|
|
{
|
|
if ($this->activeTab === 'volumes') {
|
|
// Keep snapshot small while the volumes tab is shown.
|
|
$this->fileStorage = collect();
|
|
|
|
return;
|
|
}
|
|
|
|
$query = $this->resource->fileStorages();
|
|
|
|
if ($this->activeTab === 'files') {
|
|
$query->where('is_directory', false);
|
|
} else {
|
|
$query->where('is_directory', true);
|
|
}
|
|
|
|
$this->fileStorage = $query->get()->each(function (LocalFileVolume $fs): void {
|
|
if ($this->activeTab !== 'files') {
|
|
$fs->content = null;
|
|
|
|
return;
|
|
}
|
|
|
|
if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
|
|
$fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
|
|
}
|
|
});
|
|
}
|
|
|
|
public function getFilesProperty()
|
|
{
|
|
return collect($this->fileStorage)->where('is_directory', false);
|
|
}
|
|
|
|
public function getDirectoriesProperty()
|
|
{
|
|
return collect($this->fileStorage)->where('is_directory', true);
|
|
}
|
|
|
|
public function getVolumeCountProperty()
|
|
{
|
|
return $this->cachedVolumeCount;
|
|
}
|
|
|
|
public function getFileCountProperty()
|
|
{
|
|
return $this->cachedFileCount;
|
|
}
|
|
|
|
public function getDirectoryCountProperty()
|
|
{
|
|
return $this->cachedDirectoryCount;
|
|
}
|
|
|
|
public function submitPersistentVolume()
|
|
{
|
|
try {
|
|
$this->authorize('update', $this->resource);
|
|
|
|
$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],
|
|
], array_merge(ValidationPatterns::volumeNameMessages(), [
|
|
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
|
|
]));
|
|
|
|
$name = $this->resource->uuid.'-'.$this->name;
|
|
|
|
LocalPersistentVolume::create([
|
|
'name' => $name,
|
|
'mount_path' => $this->mount_path,
|
|
'host_path' => $this->host_path,
|
|
'resource_id' => $this->resource->id,
|
|
'resource_type' => $this->resource->getMorphClass(),
|
|
]);
|
|
$this->clearForm();
|
|
$this->activeTab = 'volumes';
|
|
$this->refreshStorages();
|
|
$this->dispatch('configurationChanged');
|
|
$this->dispatch('success', 'Volume added successfully');
|
|
$this->dispatch('closeStorageModal', 'volume');
|
|
$this->dispatch('refreshStorages');
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function submitFileStorage()
|
|
{
|
|
try {
|
|
$this->authorize('update', $this->resource);
|
|
|
|
$this->validate([
|
|
'file_storage_path' => 'required|string',
|
|
'file_storage_content' => 'nullable|string',
|
|
]);
|
|
|
|
$this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path');
|
|
|
|
$fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path');
|
|
|
|
LocalFileVolume::create([
|
|
'fs_path' => $fs_path,
|
|
'mount_path' => $this->file_storage_path,
|
|
'content' => $this->file_storage_content,
|
|
'is_directory' => false,
|
|
'resource_id' => $this->resource->id,
|
|
'resource_type' => get_class($this->resource),
|
|
]);
|
|
|
|
$this->clearForm();
|
|
$this->activeTab = 'files';
|
|
$this->refreshStorages();
|
|
$this->dispatch('configurationChanged');
|
|
$this->dispatch('success', 'File mount added successfully');
|
|
$this->dispatch('closeStorageModal', 'file');
|
|
$this->dispatch('refreshStorages');
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function submitHostFileStorage()
|
|
{
|
|
try {
|
|
$this->authorize('update', $this->resource);
|
|
|
|
$this->validate([
|
|
'host_file_storage_source' => 'required|string',
|
|
'host_file_storage_destination' => 'required|string',
|
|
]);
|
|
|
|
$this->host_file_storage_source = validateHostFileMountPath($this->host_file_storage_source, 'host file source path');
|
|
$this->host_file_storage_destination = validateFileMountPath($this->host_file_storage_destination, 'host file destination path');
|
|
|
|
LocalFileVolume::create([
|
|
'fs_path' => $this->host_file_storage_source,
|
|
'mount_path' => $this->host_file_storage_destination,
|
|
'content' => null,
|
|
'is_directory' => false,
|
|
'is_host_file' => true,
|
|
'resource_id' => $this->resource->id,
|
|
'resource_type' => get_class($this->resource),
|
|
]);
|
|
|
|
$this->clearForm();
|
|
$this->activeTab = 'files';
|
|
$this->refreshStorages();
|
|
$this->dispatch('configurationChanged');
|
|
$this->dispatch('success', 'Host file mount added successfully');
|
|
$this->dispatch('closeStorageModal', 'host-file');
|
|
$this->dispatch('refreshStorages');
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function submitFileStorageDirectory()
|
|
{
|
|
try {
|
|
$this->authorize('update', $this->resource);
|
|
|
|
$this->validate([
|
|
'file_storage_directory_source' => 'required|string',
|
|
'file_storage_directory_destination' => 'required|string',
|
|
]);
|
|
|
|
$this->file_storage_directory_source = trim($this->file_storage_directory_source);
|
|
$this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
|
|
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
|
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
|
|
|
// Validate paths to prevent command injection
|
|
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
|
|
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
|
|
|
|
LocalFileVolume::create([
|
|
'fs_path' => $this->file_storage_directory_source,
|
|
'mount_path' => $this->file_storage_directory_destination,
|
|
'is_directory' => true,
|
|
'resource_id' => $this->resource->id,
|
|
'resource_type' => get_class($this->resource),
|
|
]);
|
|
|
|
$this->clearForm();
|
|
$this->activeTab = 'directories';
|
|
$this->refreshStorages();
|
|
$this->dispatch('configurationChanged');
|
|
$this->dispatch('success', 'Directory mount added successfully');
|
|
$this->dispatch('closeStorageModal', 'directory');
|
|
$this->dispatch('refreshStorages');
|
|
} catch (\Throwable $e) {
|
|
return handleError($e, $this);
|
|
}
|
|
}
|
|
|
|
public function clearForm()
|
|
{
|
|
$this->name = '';
|
|
$this->mount_path = '';
|
|
$this->host_path = null;
|
|
$this->file_storage_path = '';
|
|
$this->file_storage_content = null;
|
|
$this->file_storage_directory_destination = '';
|
|
$this->host_file_storage_source = '';
|
|
$this->host_file_storage_destination = '';
|
|
|
|
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
|
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
|
} else {
|
|
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
|
}
|
|
}
|
|
|
|
public function fileStorageHostPath(): string
|
|
{
|
|
if (method_exists($this->resource, 'workdir')) {
|
|
return $this->resource->workdir();
|
|
}
|
|
|
|
if ($this->resource->getMorphClass() === Application::class) {
|
|
return application_configuration_dir().'/'.$this->resource->uuid;
|
|
}
|
|
|
|
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
|
return database_configuration_dir().'/'.$this->resource->uuid;
|
|
}
|
|
|
|
throw new \Exception('No valid resource type for file mount storage type!');
|
|
}
|
|
|
|
public function fileStoragePreviewPath(): string
|
|
{
|
|
$path = str($this->file_storage_path)->trim();
|
|
|
|
if ($path->isEmpty()) {
|
|
return $this->fileStorageHostPath().'/';
|
|
}
|
|
|
|
return $this->fileStorageHostPath().$path->start('/')->value();
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.project.service.storage');
|
|
}
|
|
}
|