Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai committed 2026-08-08 11:26:20 +02:00
commit 003e44d123
1274 files changed
+156179 -25249

No files matched your search

@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Service;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -17,16 +18,28 @@ class DatabaseBackups extends Component
public array $parameters;
public array $backupParameters = [];
public array $query;
public bool $isImportSupported = false;
public ?ScheduledDatabaseBackup $backup = null;
public string $section = 'index';
public $s3s;
protected $listeners = ['refreshScheduledBackups' => '$refresh'];
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->parameters = array_filter(
get_route_parameters(),
fn (string $key): bool => $key !== 'backup_uuid',
ARRAY_FILTER_USE_KEY,
);
$this->query = request()->query();
$project = currentTeam()
->projects()
@@ -58,6 +71,21 @@ class DatabaseBackups extends Component
$dbType = $this->serviceDatabase->databaseType();
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
if (request()->route('backup_uuid')) {
$this->backup = $this->serviceDatabase->scheduledBackups()
->where('uuid', request()->route('backup_uuid'))
->firstOrFail();
$this->s3s = currentTeam()->s3s;
$this->backupParameters = [...$this->parameters, 'backup_uuid' => $this->backup->uuid];
$this->section = match (request()->route()?->getName()) {
'project.service.database.backup.s3' => 's3',
'project.service.database.backup.retention' => 'retention',
'project.service.database.backup.executions' => 'executions',
'project.service.database.backup.danger' => 'danger',
default => 'general',
};
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
File diff suppressed because it is too large. Load diff
@@ -77,7 +77,6 @@ class EditCompose extends Component
{
try {
$this->authorize('update', $this->service);
$this->dispatch('info', 'Saving new docker compose...');
$this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\ScheduledVolumeBackup;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
@@ -15,6 +16,7 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -34,6 +36,10 @@ class FileStorage extends Component
public bool $isReadOnly = false;
public bool $hasEnabledBackup = false;
public ?string $backupUrl = null;
#[Validate(['nullable'])]
public ?string $content = null;
@@ -65,6 +71,53 @@ class FileStorage extends Component
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
$this->refreshBackupStatus();
}
#[On('refreshVolumeBackups')]
public function refreshBackupStatus(): void
{
$backup = $this->fileStorage->is_directory
? $this->fileStorage->scheduledBackups()->first()
: null;
$this->hasEnabledBackup = $backup?->enabled ?? false;
$this->backupUrl = null;
if (! $this->hasEnabledBackup) {
return;
}
if ($this->resource instanceof ServiceDatabase) {
$this->backupUrl = route('project.service.database.backups', [
'project_uuid' => $this->resource->service->project()->uuid,
'environment_uuid' => $this->resource->service->environment->uuid,
'service_uuid' => $this->resource->service->uuid,
'stack_service_uuid' => $this->resource->uuid,
]);
return;
}
if (! $this->resource instanceof Application) {
$this->hasEnabledBackup = false;
return;
}
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
];
$hasOtherBackups = ScheduledVolumeBackup::query()
->forApplication($this->resource)
->where('id', '!=', $backup->id)
->exists();
$this->backupUrl = $hasOtherBackups
? route('project.application.backup.index', [...$parameters, 'search' => $this->fileStorage->fs_path])
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
public function syncData(bool $toModel = false): void
@@ -135,6 +188,10 @@ class FileStorage extends Component
try {
$this->authorize('update', $this->resource);
if ($this->fileStorage->scheduledBackups()->exists()) {
throw new \RuntimeException('Delete this directory backup schedule and its archives before converting it to a file.');
}
if ($this->fileStorage->is_host_file) {
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
}
@@ -162,6 +219,12 @@ class FileStorage extends Component
return 'The provided password is incorrect.';
}
if ($this->fileStorage->scheduledBackups()->exists()) {
$this->dispatch('error', 'Delete this directory backup schedule and its archives before deleting the directory.');
return false;
}
try {
$message = 'File deleted.';
if ($this->fileStorage->is_directory) {
@@ -174,6 +237,7 @@ class FileStorage extends Component
$this->fileStorage->deleteStorageOnServer();
}
$this->fileStorage->delete();
$this->dispatch('configurationChanged');
$this->dispatch('success', $message);
} catch (\Throwable $e) {
return handleError($e, $this);
+12
View File
@@ -282,6 +282,18 @@ class Index extends Component
}
}
public function enablePublicAccess(): void
{
$this->isPublic = true;
$this->instantSave();
}
public function disablePublicAccess(): void
{
$this->isPublic = false;
$this->instantSave();
}
public function instantSave()
{
try {
@@ -140,6 +140,7 @@ class StackForm extends Component
{
$this->dockerComposeRaw = $raw;
$this->submit(notify: true);
$this->dispatch('compose-save-finished');
}
public function instantSave()
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Status extends Component
{
public Service $service;
public function getListeners(): array
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus',
"echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus',
];
}
public function refreshStatus(): void
{
$this->service->refresh()->load(['applications', 'databases']);
}
public function render(): View
{
return view('livewire.project.service.status');
}
}
+116 -18
View File
@@ -37,6 +37,14 @@ class Storage extends Component
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;
@@ -57,12 +65,18 @@ class Storage extends Component
}
if ($this->resource->getMorphClass() === Application::class) {
if ($this->resource->destination->server->isSwarm()) {
$this->resource->loadMissing('destination.server', 'environment.project');
if ($this->resource->destination?->server?->isSwarm()) {
$this->isSwarm = true;
}
}
$this->refreshStorages();
// 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()
@@ -73,37 +87,110 @@ class Storage extends Component
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
// 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;
}
});
$this->resource->load('persistentStorages.resource');
}
public function getFilesProperty()
{
return $this->fileStorage->where('is_directory', false);
return collect($this->fileStorage)->where('is_directory', false);
}
public function getDirectoriesProperty()
{
return $this->fileStorage->where('is_directory', true);
return collect($this->fileStorage)->where('is_directory', true);
}
public function getVolumeCountProperty()
{
return $this->resource->persistentStorages()->count();
return $this->cachedVolumeCount;
}
public function getFileCountProperty()
{
return $this->files->count();
return $this->cachedFileCount;
}
public function getDirectoryCountProperty()
{
return $this->directories->count();
return $this->cachedDirectoryCount;
}
public function submitPersistentVolume()
@@ -130,11 +217,13 @@ class Storage extends Component
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(),
]);
$this->resource->refresh();
$this->clearForm();
$this->activeTab = 'volumes';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Volume added successfully');
$this->dispatch('closeStorageModal', 'volume');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -163,10 +252,13 @@ class Storage extends Component
'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->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -195,10 +287,13 @@ class Storage extends Component
'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->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -231,10 +326,13 @@ class Storage extends Component
'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->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -0,0 +1,144 @@
<?php
namespace App\Livewire\Project\Service\VolumeBackup;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Create extends Component
{
use AuthorizesRequests;
#[Locked]
public Service $service;
public ?string $selectedTargetKey = null;
public ?string $targetKey = null;
public bool $targetLocked = false;
public string $frequency = 'daily';
public Collection $targets;
protected function rules(): array
{
return [
'targetKey' => ['required', 'string', 'regex:/^(volume|directory):[1-9][0-9]*$/'],
'frequency' => ['required', 'string'],
];
}
public function mount(): void
{
$this->authorize('view', $this->service);
$this->targetLocked = $this->selectedTargetKey !== null;
$this->targetKey = $this->selectedTargetKey;
$this->targets = $this->availableTargets();
$this->targetKey ??= data_get($this->targets->first(), 'key');
$this->loadSelectedBackup();
}
public function updatedTargetKey(): void
{
$this->loadSelectedBackup();
}
public function submit(): void
{
$this->authorize('update', $this->service);
$this->validate();
$target = $this->selectedTarget();
if (! $target) {
$this->addError('targetKey', 'Select a volume or directory owned by this service.');
return;
}
if (! validate_cron_expression($this->frequency)) {
$this->addError('frequency', 'The frequency must be a valid cron or human expression.');
return;
}
try {
$backup = $target->scheduledBackups()->updateOrCreate([], [
'team_id' => currentTeam()->id,
'frequency' => $this->frequency,
'enabled' => true,
]);
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
redirectRoute($this, 'project.service.volume-backups.show', [
'project_uuid' => $this->service->project()->uuid,
'environment_uuid' => $this->service->environment->uuid,
'service_uuid' => $this->service->uuid,
'backup_uuid' => $backup->uuid,
]);
} catch (\Throwable $exception) {
handleError($exception, $this);
}
}
public function render()
{
return view('livewire.project.application.backup.create');
}
private function availableTargets(): Collection
{
$resources = $this->service->applications()->get()->concat($this->service->databases()->get());
$targets = collect();
foreach ($resources as $resource) {
$label = str($resource->name)->headline();
$targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [
'key' => 'volume:'.$volume->id,
'type' => 'Volume · '.$label,
'name' => $volume->name,
]));
$targets->push(...$resource->fileStorages()
->where('is_directory', true)
->where('is_host_file', false)
->orderBy('fs_path')
->get()
->map(fn (LocalFileVolume $directory): array => [
'key' => 'directory:'.$directory->id,
'type' => 'Directory · '.$label,
'name' => $directory->fs_path,
]));
}
return $this->targetLocked
? $targets->where('key', $this->selectedTargetKey)->values()
: $targets->values();
}
private function loadSelectedBackup(): void
{
$backup = $this->selectedTarget()?->scheduledBackups()->first();
if ($backup) {
$this->frequency = $backup->frequency;
}
}
private function selectedTarget(): LocalPersistentVolume|LocalFileVolume|null
{
[$type, $id] = array_pad(explode(':', (string) $this->targetKey, 2), 2, null);
if (! ctype_digit((string) $id) || ! in_array($type, ['volume', 'directory'], true)) {
return null;
}
$target = ($type === 'volume' ? LocalPersistentVolume::query() : LocalFileVolume::query())->find((int) $id);
if (! $target || ($type === 'directory' && (! $target->is_directory || $target->is_host_file))) {
return null;
}
return $target->resource?->service_id === $this->service->id ? $target : null;
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Livewire\Project\Service\VolumeBackup;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledVolumeBackup;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Index extends Component
{
use AuthorizesRequests;
public Service $service;
public array $parameters;
public string $search = '';
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
public function mount(): void
{
$this->service = $this->findService();
$this->authorize('view', $this->service);
$this->parameters = get_route_parameters();
$this->search = request()->string('search')->toString();
}
public function render(): View
{
$databaseTargets = $this->service->databases->filter(
fn (ServiceDatabase $database): bool => $database->isBackupSolutionAvailable(),
);
$databaseBackups = ScheduledDatabaseBackup::query()
->with(['database', 'latest_log', 's3'])
->withCount('executions')
->where('database_type', (new ServiceDatabase)->getMorphClass())
->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id))
->latest()
->get();
$backups = ScheduledVolumeBackup::query()
->with(['backupable.resource', 'latestExecution', 's3'])
->withCount('executions')
->forService($this->service)
->latest()
->get();
return view('livewire.project.service.volume-backup.index', [
'backups' => $backups,
'databaseBackups' => $databaseBackups,
'databaseTargets' => $databaseTargets,
]);
}
private function findService(): Service
{
$project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail();
$environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
return $environment->services()
->with(['server.settings', 'environment.project'])
->where('uuid', request()->route('service_uuid'))
->firstOrFail();
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Project\Service\VolumeBackup;
use App\Models\ScheduledVolumeBackup;
use App\Models\Service;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public Service $service;
public ScheduledVolumeBackup $backup;
public array $parameters;
public string $section = 'general';
public function mount(): void
{
$project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail();
$environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
$this->service = $environment->services()
->with(['server.settings', 'environment.project'])
->where('uuid', request()->route('service_uuid'))
->firstOrFail();
$this->authorize('view', $this->service);
$this->backup = ScheduledVolumeBackup::query()
->with('backupable.resource')
->where('uuid', request()->route('backup_uuid'))
->forService($this->service)
->firstOrFail();
$this->parameters = get_route_parameters();
$this->section = match (request()->route()?->getName()) {
'project.service.volume-backups.s3' => 's3',
'project.service.volume-backups.retention' => 'retention',
'project.service.volume-backups.executions' => 'executions',
'project.service.volume-backups.danger' => 'danger',
default => 'general',
};
}
public function render(): View
{
return view('livewire.project.service.volume-backup.show');
}
}