From fa1895c99a773b788ded183e205d9149870526dd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:55:28 +0200 Subject: [PATCH] feat(dashboard): add canvas view with active deployment overlay Add a /dashboard/canvas page that renders every team server with its resources and their running or queued deployments, backed by a new ServerCanvasController::dashboard endpoint and a per-server usage endpoint for zone headers. The dashboard remembers the list/canvas choice and each section (projects, servers) its own table/grid view. - Show Deploying/Queued pills on canvas cards and a foldable recent deployments panel; add shared CanvasControls component - Allow backup downloads from the server host via the S3 download storage (PrepareFileDownloadJob accepts a null container) - Extract backup toggle/run actions into ManagesDatabaseBackup and surface them in backup execution headers; merge S3 and retention sections into the general backup settings - Move deployments indicator to the top bar and add a main nav resource menu with submenus; simplify breadcrumbs and settings layouts - Add shared variables overview canvas and scheduled task modals - Add feature tests for dashboard canvas, view modes, navbar submenus, backup S3 downloads and scheduled task modals --- DESIGN.md | 9 +- .../Api/ServerCanvasController.php | 37 ++ app/Jobs/PrepareFileDownloadJob.php | 16 +- .../Concerns/ManagesDatabaseBackup.php | 85 +++++ app/Livewire/Concerns/RemembersCanvasView.php | 10 +- app/Livewire/Dashboard.php | 9 + app/Livewire/Dashboard/Canvas.php | 33 ++ app/Livewire/DeploymentsIndicator.php | 4 +- .../Project/Application/Backup/Show.php | 2 - .../Project/Database/Backup/Execution.php | 53 ++- app/Livewire/Project/Database/BackupEdit.php | 81 ++-- .../Project/Database/BackupExecutions.php | 26 +- .../Project/Service/BackupExecutions.php | 32 +- .../Project/Service/DatabaseBackups.php | 2 - .../Project/Service/VolumeBackup/Show.php | 2 - .../Project/Shared/ScheduledTask/All.php | 50 +++ .../Project/Shared/ScheduledTask/Show.php | 53 +-- .../Project/Shared/Storages/VolumeBackups.php | 28 ++ app/Livewire/SharedVariables/Index.php | 9 +- app/Services/ContainerFilesystemService.php | 4 +- app/Services/EnvironmentCanvas.php | 18 +- app/Services/FileDownloadStorageService.php | 42 +++ app/Services/ProjectCanvas.php | 23 +- app/Services/ServerCanvas.php | 144 ++++++- app/Services/SharedVariableCanvas.php | 75 ++++ app/Traits/HasS3Replicas.php | 42 +++ app/Traits/PreparesBackupDownloads.php | 82 ++++ config/constants.php | 2 +- docker-compose.dev-multi.yml | 5 +- other/nightly/versions.json | 4 +- resources/css/app.css | 136 +++++-- resources/css/utilities.css | 10 + resources/js/canvas/CanvasControls.svelte | 20 + .../js/canvas/deploy/DeployCanvas.svelte | 14 +- resources/js/canvas/deploy/state.svelte.js | 7 +- resources/js/canvas/deploy/styles.js | 2 +- resources/js/canvas/environment/Card.svelte | 47 ++- .../js/canvas/environment/Deployments.svelte | 86 +++++ .../environment/EnvironmentCanvas.svelte | 209 +++++++--- resources/js/canvas/environment/Panel.svelte | 8 - .../js/canvas/environment/StatusDot.svelte | 4 +- .../js/canvas/environment/Toolbar.svelte | 18 +- resources/js/canvas/environment/Zone.svelte | 94 ++++- resources/js/environment-canvas.js | 198 +++++++--- resources/js/environment-canvas.test.js | 190 ++++++++-- .../configuration-sidebar.blade.php | 54 ++- .../views/components/backup-sidebar.blade.php | 21 +- .../views/components/backup-tabs.blade.php | 8 - .../components/canvas-view-toggle.blade.php | 76 ++-- .../database/configuration-sidebar.blade.php | 46 ++- .../views/components/main-nav-menu.blade.php | 19 + resources/views/components/navbar.blade.php | 356 ++++++++++-------- .../notification/settings-layout.blade.php | 32 +- resources/views/components/reicon.blade.php | 6 +- .../components/section-heading-link.blade.php | 8 + .../components/section-heading.blade.php | 37 +- .../security/settings-layout.blade.php | 37 +- .../views/components/server/sidebar.blade.php | 62 ++- .../service-database/sidebar.blade.php | 6 +- .../service/configuration-sidebar.blade.php | 20 +- .../components/settings/layout.blade.php | 41 +- .../shared-variables/layout.blade.php | 9 +- .../shared-variables/sidebar.blade.php | 17 +- .../shared-variables/view-controls.blade.php | 24 +- .../components/team/settings-layout.blade.php | 34 +- .../views/components/top-breadcrumb.blade.php | 59 +-- .../views/components/top-user-menu.blade.php | 16 - resources/views/layouts/app.blade.php | 40 +- resources/views/livewire/dashboard.blade.php | 242 ++++++++++-- .../dashboard/active-deployments.blade.php | 47 ++- .../views/livewire/dashboard/canvas.blade.php | 14 + .../livewire/deployments-indicator.blade.php | 64 ++-- .../livewire/destination/index.blade.php | 2 +- .../livewire/destination/sidebar.blade.php | 10 +- .../views/livewire/profile/index.blade.php | 220 ++++++----- .../project/application/backup/show.blade.php | 2 +- .../application/configuration.blade.php | 2 - .../project/database/backup-edit.blade.php | 10 +- .../database/backup-edit/general.blade.php | 8 +- .../database/backup-edit/retention.blade.php | 60 ++- .../project/database/backup-edit/s3.blade.php | 54 ++- .../database/backup-executions.blade.php | 11 +- .../database/backup/execution.blade.php | 19 +- .../project/environment-canvas.blade.php | 2 +- .../livewire/project/new/select.blade.php | 10 +- .../livewire/project/project-canvas.blade.php | 2 +- .../project/projects-canvas.blade.php | 2 +- .../service/backup-executions.blade.php | 12 +- .../project/service/configuration.blade.php | 36 +- .../livewire/project/service/index.blade.php | 6 +- .../service/volume-backup/index.blade.php | 2 +- .../project/shared/file-browser.blade.php | 24 +- .../shared/scheduled-task/all.blade.php | 20 +- .../shared/scheduled-task/show.blade.php | 47 ++- .../shared/storages/volume-backups.blade.php | 31 +- .../volume-backups/executions.blade.php | 2 +- .../storages/volume-backups/general.blade.php | 8 +- .../volume-backups/retention.blade.php | 60 ++- .../storages/volume-backups/s3.blade.php | 62 ++- .../views/livewire/server/canvas.blade.php | 6 +- .../views/livewire/server/index.blade.php | 10 +- .../views/livewire/server/navbar.blade.php | 2 +- .../partials/localhost-general.blade.php | 4 - .../shared-variables/canvas.blade.php | 4 +- .../livewire/shared-variables/index.blade.php | 158 +++++--- .../shared/list-search-controls.blade.php | 8 +- .../livewire/source/github/change.blade.php | 10 +- .../views/livewire/storage/index.blade.php | 2 +- .../views/livewire/storage/show.blade.php | 10 +- .../views/livewire/tags/canvas.blade.php | 2 +- resources/views/source/all.blade.php | 2 +- routes/web.php | 79 ++-- tests/Feature/BackupEditValidationTest.php | 119 ++++-- .../Feature/BackupS3DestinationsFormTest.php | 6 +- .../Feature/BackupS3DownloadFallbackTest.php | 183 +++++++++ tests/Feature/BackupS3DownloadTest.php | 287 ++++++++++++++ .../BackupS3StorageListResourceTeamTest.php | 2 +- tests/Feature/DashboardCanvasTest.php | 132 +++++++ tests/Feature/DashboardDeploymentsTest.php | 1 - tests/Feature/DashboardViewModeTest.php | 44 +++ tests/Feature/EnvironmentCanvasTest.php | 6 +- ...stanceBackupMultipleS3DestinationsTest.php | 2 +- tests/Feature/NavbarSubmenuTest.php | 133 +++++++ tests/Feature/ProjectCanvasTest.php | 4 +- tests/Feature/ResourceMenuInMainNavTest.php | 118 ++++++ tests/Feature/ScheduleAuditTest.php | 24 ++ tests/Feature/ScheduledTaskModalTest.php | 102 +++++ tests/Feature/ServerCanvasTest.php | 81 +++- tests/Feature/ServiceResourceRoutingTest.php | 4 +- tests/Feature/SharedVariablesOverviewTest.php | 90 +++++ .../Feature/StableNestedLivewireKeysTest.php | 2 +- tests/Feature/VolumeBackupTest.php | 60 ++- tests/v4/Browser/DeployCanvasTest.php | 4 +- versions.json | 4 +- 134 files changed, 4255 insertions(+), 1594 deletions(-) create mode 100644 app/Livewire/Concerns/ManagesDatabaseBackup.php create mode 100644 app/Livewire/Dashboard/Canvas.php create mode 100644 app/Traits/PreparesBackupDownloads.php create mode 100644 resources/js/canvas/CanvasControls.svelte create mode 100644 resources/js/canvas/environment/Deployments.svelte create mode 100644 resources/views/components/main-nav-menu.blade.php create mode 100644 resources/views/components/section-heading-link.blade.php create mode 100644 resources/views/livewire/dashboard/canvas.blade.php create mode 100644 tests/Feature/BackupS3DownloadFallbackTest.php create mode 100644 tests/Feature/BackupS3DownloadTest.php create mode 100644 tests/Feature/DashboardCanvasTest.php create mode 100644 tests/Feature/DashboardViewModeTest.php create mode 100644 tests/Feature/NavbarSubmenuTest.php create mode 100644 tests/Feature/ResourceMenuInMainNavTest.php create mode 100644 tests/Feature/ScheduledTaskModalTest.php create mode 100644 tests/Feature/SharedVariablesOverviewTest.php diff --git a/DESIGN.md b/DESIGN.md index a482fa3e50..02757f53f7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -229,8 +229,13 @@ sub-header. The dashboard is a compact overview, not a metrics wall. It has no page header: live active deployments come first as a compact table, followed by traffic analytics when a server has it enabled, then two full-width sections (Projects, -then Servers) that follow the projects-page grid pattern. Each section uses -`x-section-heading` linking to its full index. Communicate server health with +then Servers). Each section uses `x-section-heading` with a small "View all" +link to its full index (next to the switcher) and a table / grid `x-canvas-view-toggle` (no canvas +link). Table is the default; each section remembers its own choice. A page-level +List / Canvas switch at the top opens `/dashboard/canvas`: the servers canvas +with a "Deploying" / "Queued" pill on each application card that has an active +deployment, and a foldable Recent deployments panel at the bottom left (active, then +recent; a row shows its card, the status opens the deployment). Communicate server health with the shared status badge. ### Top-level dashboard destinations diff --git a/app/Http/Controllers/Api/ServerCanvasController.php b/app/Http/Controllers/Api/ServerCanvasController.php index e48b8c4aff..79c33553ad 100644 --- a/app/Http/Controllers/Api/ServerCanvasController.php +++ b/app/Http/Controllers/Api/ServerCanvasController.php @@ -29,6 +29,22 @@ class ServerCanvasController extends Controller return response()->json($this->canvas->payload($teamId, $servers, null, $this->canReadSensitive($request), $this->canEdit($request, $teamId))); } + /** + * Dashboard canvas: every server of the team with its resources and their running or queued deployments. + */ + public function dashboard(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $this->authorize('viewAny', Server::class); + + $servers = Server::query()->whereTeamId($teamId)->with('settings')->orderBy('name')->get(); + + return response()->json($this->canvas->dashboardPayload($teamId, $servers, $this->canReadSensitive($request), $this->canEdit($request, $teamId))); + } + public function show(Request $request, string $uuid): JsonResponse { $teamId = getTeamIdFromToken(); @@ -73,6 +89,27 @@ class ServerCanvasController extends Controller return response()->json(['nodes' => $this->canvas->unmanagedContainerNodes($server)->all()]); } + /** + * Latest CPU, memory and disk usage of one server for its zone header on the canvas. + */ + public function usage(string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + $this->authorize('view', $server); + if (! $server->settings->is_metrics_enabled) { + return response()->json(['cpu' => null, 'memory' => null, 'disk' => null]); + } + + return response()->json($this->canvas->usage($server)); + } + private function canReadSensitive(Request $request): bool { return (bool) $request->attributes->get('can_read_sensitive', false); diff --git a/app/Jobs/PrepareFileDownloadJob.php b/app/Jobs/PrepareFileDownloadJob.php index 01a8f47769..b36d5ccd96 100644 --- a/app/Jobs/PrepareFileDownloadJob.php +++ b/app/Jobs/PrepareFileDownloadJob.php @@ -14,8 +14,10 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; /** - * Uploads a file browser download from the managed server to the instance - * download S3 storage and stores a presigned link for the FileBrowser to open. + * Uploads a download from the managed server to the instance download S3 + * storage and stores a presigned link for the Livewire component to open. + * The source is a path in $container, or a file on the server host (for + * example a backup) when $container is null. */ class PrepareFileDownloadJob implements ShouldQueue { @@ -29,7 +31,7 @@ class PrepareFileDownloadJob implements ShouldQueue public string $downloadId, public int $userId, public int $serverId, - public string $container, + public ?string $container, public string $path, public bool $isDir, public string $downloadName, @@ -55,12 +57,16 @@ class PrepareFileDownloadJob implements ShouldQueue DeleteFileDownloadJob::dispatch($storage->id, $key)->delay(now()->addHours(FileDownloadStorageService::OBJECT_TTL_HOURS)); $upload = $downloads->uploadUrl($storage, $key, $this->downloadName); - (new ContainerFilesystemService($server, $this->container))->uploadToS3($this->path, $this->isDir, $upload['url'], $upload['headers']); + if ($this->container === null) { + instant_remote_process([FileDownloadStorageService::buildHostFileUploadCommand($this->path, $upload['url'], $upload['headers'])], $server, timeout: 3600); + } else { + (new ContainerFilesystemService($server, $this->container))->uploadToS3($this->path, $this->isDir, $upload['url'], $upload['headers']); + } $this->store(['status' => 'ready', 'url' => $downloads->downloadUrl($storage, $key)]); } catch (\Throwable $e) { // The message can contain the S3 endpoint or a presigned URL; keep it in the log only. - Log::warning('File browser S3 download failed', ['download_id' => $this->downloadId, 'error' => $e->getMessage()]); + Log::warning('S3 download failed', ['download_id' => $this->downloadId, 'error' => $e->getMessage()]); $this->store(['status' => 'failed', 'message' => 'Download failed. The file could not be uploaded to the download storage. Try again, or ask your administrator to check the storage settings.']); } } diff --git a/app/Livewire/Concerns/ManagesDatabaseBackup.php b/app/Livewire/Concerns/ManagesDatabaseBackup.php new file mode 100644 index 0000000000..64eaffa1a1 --- /dev/null +++ b/app/Livewire/Concerns/ManagesDatabaseBackup.php @@ -0,0 +1,85 @@ +authorize('manageBackups', $backup->database); + + $backup->enabled = ! $backup->enabled; + $changedFields = auditChangedFields($backup); + $backup->save(); + + $database = $backup->database; + auditLog('ui.database.backup_schedule_updated', [ + 'team_id' => $database?->team()?->id, + 'database_uuid' => $database?->uuid, + 'database_name' => $database?->name, + 'backup_uuid' => $backup->uuid, + 'changed_fields' => $changedFields, + ]); + $this->dispatch('success', $backup->enabled ? 'Backup enabled.' : 'Backup disabled.'); + + return $backup->enabled; + } + + /** + * Queue a backup run now and redirect to the executions page of the backup. + */ + protected function queueDatabaseBackup(ScheduledDatabaseBackup $backup): Redirector|RedirectResponse|null + { + $this->authorize('manageBackups', $backup->database); + + $database = $backup->database->refresh(); + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return null; + } + + DatabaseBackupJob::dispatch($backup); + auditLog('ui.database.backup_started', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $backup->uuid, + ]); + $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); + + if ($database instanceof ServiceDatabase) { + return redirect()->route('project.service.database.backup.executions', [ + 'project_uuid' => $database->service->project()->uuid, + 'environment_uuid' => $database->service->environment->uuid, + 'service_uuid' => $database->service->uuid, + 'stack_service_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ]); + } + + // Instance databases (e.g. coolify-db) have no project/environment. + // Stay on the current page (settings.backup) instead of redirecting. + $project = $database->project(); + $environment = $database->environment; + if (! $project || ! $environment) { + return null; + } + + return redirect()->route('project.database.backup.executions', [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ]); + } +} diff --git a/app/Livewire/Concerns/RemembersCanvasView.php b/app/Livewire/Concerns/RemembersCanvasView.php index d8bad86d4d..4d7e4f29d8 100644 --- a/app/Livewire/Concerns/RemembersCanvasView.php +++ b/app/Livewire/Concerns/RemembersCanvasView.php @@ -13,7 +13,7 @@ use Livewire\Attributes\Renderless; */ trait RemembersCanvasView { - private const array CANVAS_VIEW_PAGES = ['servers', 'server_resources', 'projects', 'project', 'environment', 'tags', 'team_variables', 'project_variables', 'environment_variables', 'server_variables']; + private const array CANVAS_VIEW_PAGES = ['servers', 'server_resources', 'projects', 'project', 'environment', 'tags', 'team_variables', 'project_variables', 'environment_variables', 'server_variables', 'dashboard', 'dashboard_projects', 'dashboard_servers']; private const array LIST_VIEWS = ['table', 'grid', 'list']; @@ -60,9 +60,13 @@ trait RemembersCanvasView */ protected function listView(string $page, string $default): string { - $view = request()->query('view') ?? request()->cookie($this->viewCookieName($page)); + foreach ([request()->query('view'), request()->cookie($this->viewCookieName($page))] as $view) { + if (in_array($view, ['table', 'grid'], true)) { + return $view; + } + } - return in_array($view, ['table', 'grid'], true) ? $view : $default; + return $default; } private function viewCookieName(string $page): string diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 2fc2553512..aa32fe6770 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -11,6 +12,8 @@ use Livewire\Component; class Dashboard extends Component { + use RemembersCanvasView; + public Collection $projects; public Collection $servers; @@ -19,6 +22,12 @@ class Dashboard extends Component public function mount() { + if ($this->prefersCanvasView('dashboard')) { + $this->redirectRoute('dashboard.canvas', navigate: true); + + return; + } + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); $this->servers = Server::ownedByCurrentTeamCached(); $this->projects = Project::ownedByCurrentTeam() diff --git a/app/Livewire/Dashboard/Canvas.php b/app/Livewire/Dashboard/Canvas.php new file mode 100644 index 0000000000..8aba6c5f54 --- /dev/null +++ b/app/Livewire/Dashboard/Canvas.php @@ -0,0 +1,33 @@ +teamId = currentTeam()->id; + $this->rememberView('dashboard', 'canvas'); + } + + public function render(): View + { + return view('livewire.dashboard.canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index e6b68a62b7..55dc5b9a85 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -10,9 +10,9 @@ use Livewire\Component; class DeploymentsIndicator extends Component { /** - * Where the indicator is rendered: 'sidebar' (desktop sidebar footer) or 'mobile' (mobile top bar). + * Where the indicator is rendered: 'topbar' (desktop top bar) or 'mobile' (mobile top bar). */ - public string $variant = 'sidebar'; + public string $variant = 'topbar'; #[Computed] public function deployments() diff --git a/app/Livewire/Project/Application/Backup/Show.php b/app/Livewire/Project/Application/Backup/Show.php index 5e10f0c8ac..c07e49852a 100644 --- a/app/Livewire/Project/Application/Backup/Show.php +++ b/app/Livewire/Project/Application/Backup/Show.php @@ -40,8 +40,6 @@ class Show extends Component ->firstOrFail(); $this->parameters = get_route_parameters(); $this->section = match (request()->route()?->getName()) { - 'project.application.backup.s3' => 's3', - 'project.application.backup.retention' => 'retention', 'project.application.backup.executions' => 'executions', 'project.application.backup.danger' => 'danger', default => 'general', diff --git a/app/Livewire/Project/Database/Backup/Execution.php b/app/Livewire/Project/Database/Backup/Execution.php index 726ee3b698..02e8e8c0fe 100644 --- a/app/Livewire/Project/Database/Backup/Execution.php +++ b/app/Livewire/Project/Database/Backup/Execution.php @@ -2,12 +2,21 @@ namespace App\Livewire\Project\Database\Backup; +use App\Livewire\Concerns\ManagesDatabaseBackup; +use App\Livewire\Project\Database\BackupEdit; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Auth; use Livewire\Component; +use Livewire\Features\SupportRedirects\Redirector; class Execution extends Component { + use AuthorizesRequests; + use ManagesDatabaseBackup; + public $database; public ?ScheduledDatabaseBackup $backup; @@ -20,6 +29,22 @@ class Execution extends Component public string $section = 'general'; + public function getListeners(): array + { + // Keep the header backup actions in sync when the database starts/stops. + $listeners = [ + 'databaseUpdated' => '$refresh', + 'backupEnabledChanged' => 'syncBackupEnabled', + ]; + + $user = Auth::user(); + if ($user) { + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = '$refresh'; + } + + return $listeners; + } + public function mount() { $backup_uuid = request()->route('backup_uuid'); @@ -50,14 +75,38 @@ class Execution extends Component ->get(); $this->parameters = get_route_parameters(); $this->section = match (request()->route()?->getName()) { - 'project.database.backup.s3' => 's3', - 'project.database.backup.retention' => 'retention', 'project.database.backup.executions' => 'executions', 'project.database.backup.danger' => 'danger', default => 'general', }; } + public function backupNow(): Redirector|RedirectResponse|null + { + try { + return $this->queueDatabaseBackup($this->backup); + } catch (\Throwable $e) { + handleError($e, $this); + + return null; + } + } + + public function toggleEnabled(): void + { + try { + $this->toggleDatabaseBackup($this->backup); + $this->dispatch('backupEnabledChanged')->to(BackupEdit::class); + } catch (\Throwable $e) { + $this->dispatch('error', $e->getMessage()); + } + } + + public function syncBackupEnabled(): void + { + $this->backup->refresh(); + } + public function render() { return view('livewire.project.database.backup.execution'); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index a7c49eeb58..81be813f64 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -2,7 +2,8 @@ namespace App\Livewire\Project\Database; -use App\Jobs\DatabaseBackupJob; +use App\Livewire\Concerns\ManagesDatabaseBackup; +use App\Livewire\Project\Database\Backup\Execution; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; @@ -21,11 +22,15 @@ class BackupEdit extends Component { use AuthorizesRequests; use ListensToTeamChannel; + use ManagesDatabaseBackup; public ScheduledDatabaseBackup $backup; public string $section = 'general'; + /** The page shows the backup actions in the top header bar instead. */ + public bool $actionsInHeader = false; + #[Locked] public $availableS3Storages; @@ -96,7 +101,10 @@ class BackupEdit extends Component public function getListeners(): array { // Keep "Backup Now" in sync when the database starts/stops without a full page refresh. - $listeners = ['databaseUpdated' => 'refreshStatus']; + $listeners = [ + 'databaseUpdated' => 'refreshStatus', + 'backupEnabledChanged' => 'syncBackupEnabled', + ]; $user = Auth::user(); if (! $user) { @@ -127,6 +135,15 @@ class BackupEdit extends Component } } + public function hydrate(): void + { + // Livewire restores a deleted backup as an empty model. Requests that arrive after the delete + // (for example status events before the redirect completes) must not render it. + if (! $this->backup->exists) { + $this->skipRender(); + } + } + public function refreshStatus(): void { $database = $this->backup->database; @@ -268,50 +285,9 @@ class BackupEdit extends Component public function backupNow() { try { - $this->authorize('manageBackups', $this->backup->database); + $this->status = $this->backup->database->refresh()->status; - $database = $this->backup->database->refresh(); - $this->status = $database->status; - if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { - $this->dispatch('error', 'The database must be running to start a backup.'); - - return; - } - - DatabaseBackupJob::dispatch($this->backup); - $database = $this->backup->database; - auditLog('ui.database.backup_started', [ - 'team_id' => $database->team()?->id, - 'database_uuid' => $database->uuid, - 'database_name' => $database->name, - 'backup_uuid' => $this->backup->uuid, - ]); - $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); - - if ($database instanceof ServiceDatabase) { - return redirect()->route('project.service.database.backup.executions', [ - 'project_uuid' => $database->service->project()->uuid, - 'environment_uuid' => $database->service->environment->uuid, - 'service_uuid' => $database->service->uuid, - 'stack_service_uuid' => $database->uuid, - 'backup_uuid' => $this->backup->uuid, - ]); - } - - // Instance databases (e.g. coolify-db) have no project/environment. - // Stay on the current page (settings.backup) instead of redirecting. - $project = $database->project(); - $environment = $database->environment; - if (! $project || ! $environment) { - return null; - } - - return redirect()->route('project.database.backup.executions', [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'database_uuid' => $database->uuid, - 'backup_uuid' => $this->backup->uuid, - ]); + return $this->queueDatabaseBackup($this->backup); } catch (\Throwable $e) { return handleError($e, $this); } @@ -332,19 +308,18 @@ class BackupEdit extends Component public function toggleEnabled(): void { try { - $this->authorize('manageBackups', $this->backup->database); - - $this->backupEnabled = ! $this->backupEnabled; - $this->backup->enabled = $this->backupEnabled; - $changedFields = auditChangedFields($this->backup); - $this->backup->save(); - $this->auditBackupScheduleUpdated($changedFields); - $this->dispatch('success', $this->backupEnabled ? 'Backup enabled.' : 'Backup disabled.'); + $this->backupEnabled = $this->toggleDatabaseBackup($this->backup); + $this->dispatch('backupEnabledChanged')->to(Execution::class); } catch (\Throwable $e) { $this->dispatch('error', $e->getMessage()); } } + public function syncBackupEnabled(): void + { + $this->backupEnabled = $this->backup->refresh()->enabled; + } + public function updatedS3StorageIds(): void { $this->instantSave(); diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index f9d4658688..168076238f 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -5,6 +5,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; use App\Traits\ListensToTeamChannel; +use App\Traits\PreparesBackupDownloads; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; @@ -13,6 +14,7 @@ class BackupExecutions extends Component { use AuthorizesRequests; use ListensToTeamChannel; + use PreparesBackupDownloads; public ?ScheduledDatabaseBackup $backup = null; @@ -126,9 +128,25 @@ class BackupExecutions extends Component return true; } - public function download_file($exeuctionId) + public function download_file($executionId): void { - return redirect()->route('download.backup', $exeuctionId); + try { + $this->authorize('manageBackups', $this->database); + $execution = $this->backup->executions()->whereKey($executionId)->first(); + if (! $execution) { + $this->dispatch('error', 'Backup execution not found.'); + + return; + } + + $this->prepareBackupDownload( + $execution->local_storage_deleted ? null : $this->server(), + $execution->filename, + route('download.backup', $execution->id), + ); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function refreshBackupExecutions(): void @@ -223,6 +241,8 @@ class BackupExecutions extends Component public function render() { - return view('livewire.project.database.backup-executions'); + return view('livewire.project.database.backup-executions', [ + 's3Downloads' => $this->s3BackupDownloadsEnabled(), + ]); } } diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php index b319f66412..d4b63e4766 100644 --- a/app/Livewire/Project/Service/BackupExecutions.php +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -9,6 +9,7 @@ use App\Models\ScheduledVolumeBackupExecution; use App\Models\Service; use App\Models\ServiceDatabase; use App\Traits\ListensToTeamChannel; +use App\Traits\PreparesBackupDownloads; use Illuminate\Contracts\View\View; use Illuminate\Database\Query\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -20,6 +21,7 @@ class BackupExecutions extends Component { use AuthorizesRequests; use ListensToTeamChannel; + use PreparesBackupDownloads; use WithPagination; public Service $service; @@ -62,6 +64,29 @@ class BackupExecutions extends Component $this->executionModalOpen = true; } + public function downloadExecution(string $executionUuid): void + { + $this->authorize('update', $this->service); + $row = $this->executionQuery($executionUuid)->first(); + abort_unless($row, 404); + + try { + if ($row->type === 'database') { + $execution = ScheduledDatabaseBackupExecution::query()->with('scheduledDatabaseBackup')->findOrFail($row->id); + $server = $execution->scheduledDatabaseBackup->server(); + $streamUrl = route('download.backup', $execution->id); + } else { + $execution = ScheduledVolumeBackupExecution::query()->with('scheduledVolumeBackup')->findOrFail($row->id); + $server = $execution->scheduledVolumeBackup->server(); + $streamUrl = route('download.volume-backup', $execution->id); + } + + $this->prepareBackupDownload($execution->local_storage_deleted ? null : $server, $execution->filename, $streamUrl); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function closeExecutionModal(): void { $this->executionModalOpen = false; @@ -80,6 +105,7 @@ class BackupExecutions extends Component return view('livewire.project.service.backup-executions', [ 'executions' => $executions, + 's3Downloads' => $this->s3BackupDownloadsEnabled(), ]); } @@ -114,11 +140,11 @@ class BackupExecutions extends Component private function formatExecutions(Collection $rows): Collection { $databaseExecutions = ScheduledDatabaseBackupExecution::query() - ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3', 'scheduledDatabaseBackup.s3Storages']) + ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3', 'scheduledDatabaseBackup.s3Storages', 's3Replicas']) ->whereIn('id', $rows->where('type', 'database')->pluck('id')) ->get()->keyBy('id'); $volumeExecutions = ScheduledVolumeBackupExecution::query() - ->with(['scheduledVolumeBackup.backupable.resource', 's3']) + ->with(['scheduledVolumeBackup.backupable.resource', 's3', 's3Replicas']) ->whereIn('id', $rows->where('type', 'storage')->pluck('id')) ->get()->keyBy('id'); @@ -156,7 +182,7 @@ class BackupExecutions extends Component 'size' => $execution->size, 'message' => $execution->message, 'filename' => $execution->filename, - 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted + 'download_url' => $execution->status === 'success' && $execution->isDownloadable() ? route($isDatabase ? 'download.backup' : 'download.volume-backup', $execution->id) : null, ]; diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 71885094aa..c778572f94 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -84,8 +84,6 @@ class DatabaseBackups extends Component ->get(); $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', diff --git a/app/Livewire/Project/Service/VolumeBackup/Show.php b/app/Livewire/Project/Service/VolumeBackup/Show.php index 10abeb3bf4..8ca50d458a 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Show.php +++ b/app/Livewire/Project/Service/VolumeBackup/Show.php @@ -37,8 +37,6 @@ class Show extends Component ->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', diff --git a/app/Livewire/Project/Shared/ScheduledTask/All.php b/app/Livewire/Project/Shared/ScheduledTask/All.php index b58e4f97a9..124c907210 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/All.php +++ b/app/Livewire/Project/Shared/ScheduledTask/All.php @@ -2,13 +2,18 @@ namespace App\Livewire\Project\Shared\ScheduledTask; +use Illuminate\Contracts\View\View; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Attributes\On; +use Livewire\Attributes\Url; use Livewire\Component; class All extends Component { + use AuthorizesRequests; + #[Locked] public $resource; @@ -19,6 +24,18 @@ class All extends Component public ?string $variables = null; + #[Url(as: 'task_uuid', except: '')] + public string $taskUuid = ''; + + public bool $taskModalOpen = false; + + public function getListeners(): array + { + return [ + 'modalClosed' => 'closeTask', + ]; + } + public function mount() { $this->parameters = get_route_parameters(); @@ -34,6 +51,30 @@ class All extends Component $this->containerNames = collect([]); } } + + if ($this->taskUuid !== '') { + $this->openTask($this->taskUuid); + } + } + + public function openTask(string $taskUuid): void + { + $this->authorize('view', $this->resource); + $this->taskUuid = $this->resource->scheduled_tasks()->where('uuid', $taskUuid)->firstOrFail()->uuid; + $this->taskModalOpen = true; + } + + public function closeTask(): void + { + $this->taskUuid = ''; + $this->taskModalOpen = false; + $this->resource->refresh(); + } + + #[On('scheduledTaskDeleted')] + public function taskDeleted(): void + { + $this->closeTask(); } #[On('refreshTasks')] @@ -41,4 +82,13 @@ class All extends Component { $this->resource->refresh(); } + + public function render(): View + { + return view('livewire.project.shared.scheduled-task.all', [ + 'selectedTask' => $this->taskUuid === '' + ? null + : $this->resource->scheduled_tasks()->where('uuid', $this->taskUuid)->first(), + ]); + } } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 4419b2e701..397ecf4710 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -45,46 +45,13 @@ class Show extends Component #[Validate(['integer', 'required', 'min:60', 'max:36000'])] public $timeout = 300; - #[Locked] - public ?string $application_uuid; - - #[Locked] - public ?string $service_uuid; - - #[Locked] - public string $task_uuid; - - public function mount() + public function mount(Application|Service $resource, ScheduledTask $task): void { - try { - $task_uuid = request()->route('task_uuid'); - $project_uuid = request()->route('project_uuid'); - $environment_uuid = request()->route('environment_uuid'); - $application_uuid = request()->route('application_uuid'); - $service_uuid = request()->route('service_uuid'); - - $this->task_uuid = $task_uuid; - if ($application_uuid) { - $this->type = 'application'; - $this->application_uuid = $application_uuid; - $this->resource = Application::ownedByCurrentTeam()->where('uuid', $application_uuid)->firstOrFail(); - } elseif ($service_uuid) { - $this->type = 'service'; - $this->service_uuid = $service_uuid; - $this->resource = Service::ownedByCurrentTeamCached()->where('uuid', $service_uuid)->firstOrFail(); - } - $this->parameters = [ - 'environment_uuid' => $environment_uuid, - 'project_uuid' => $project_uuid, - 'application_uuid' => $application_uuid, - 'service_uuid' => $service_uuid, - ]; - - $this->task = $this->resource->scheduled_tasks()->where('uuid', $task_uuid)->firstOrFail(); - $this->syncData(); - } catch (\Exception $e) { - return handleError($e); - } + $this->resource = $resource; + $this->type = $resource->type(); + $this->parameters = get_route_parameters(); + $this->task = $resource->scheduled_tasks()->whereKey($task->id)->firstOrFail(); + $this->syncData(); } /** @@ -178,12 +145,8 @@ class Show extends Component $this->authorize('delete', $this->task); $this->task->delete(); auditLog('ui.scheduled_task.deleted', $this->taskAuditContext()); - - if ($this->type === 'application') { - return redirectRoute($this, 'project.application.scheduled-tasks.show', $this->parameters); - } else { - return redirectRoute($this, 'project.service.scheduled-tasks.show', $this->parameters); - } + $this->dispatch('success', 'Scheduled task deleted.'); + $this->dispatch('scheduledTaskDeleted')->to(All::class); } catch (\Exception $e) { return handleError($e); } diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 083585fee6..44cd519850 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -11,6 +11,7 @@ use App\Models\LocalPersistentVolume; use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; use App\Models\Service; +use App\Traits\PreparesBackupDownloads; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; @@ -24,6 +25,7 @@ use Throwable; class VolumeBackups extends Component { use AuthorizesRequests; + use PreparesBackupDownloads; use WithPagination; public LocalPersistentVolume|LocalFileVolume $storage; @@ -34,6 +36,9 @@ class VolumeBackups extends Component public string $section = 'general'; + /** The page shows the backup actions in the top header bar instead. */ + public bool $actionsInHeader = false; + public string $frequency = 'daily'; public bool $enabled = false; @@ -329,6 +334,28 @@ class VolumeBackups extends Component $this->dispatch('success', 'Backup recovery queued.'); } + public function downloadBackup(int $executionId): void + { + $this->authorize('update', $this->resource); + + $execution = $this->backup?->executions()->whereKey($executionId)->first(); + if (! $execution) { + $this->dispatch('error', 'Backup execution not found.'); + + return; + } + + try { + $this->prepareBackupDownload( + $execution->local_storage_deleted ? null : $this->backup->server(), + $execution->filename, + route('download.volume-backup', $execution->id), + ); + } catch (Throwable $e) { + handleError($e, $this); + } + } + public function deleteBackup(int $executionId, string $password, array $selectedActions = []): bool|string { $this->authorize('update', $this->resource); @@ -383,6 +410,7 @@ class VolumeBackups extends Component return view('livewire.project.shared.storages.volume-backups', [ 'executions' => $executions ?? collect(), 'latestExecution' => $this->backup?->executions()->first(), + 's3Downloads' => $this->s3BackupDownloadsEnabled(), 'deleteScheduleCheckboxes' => [ ['id' => 'delete_associated_backups_locally', 'label' => 'Delete all local archives created by this schedule.'], ['id' => 'delete_associated_backups_s3', 'label' => 'Delete all S3 archives created by this schedule.'], diff --git a/app/Livewire/SharedVariables/Index.php b/app/Livewire/SharedVariables/Index.php index 61c31e9f83..fe8ccc1802 100644 --- a/app/Livewire/SharedVariables/Index.php +++ b/app/Livewire/SharedVariables/Index.php @@ -2,12 +2,17 @@ namespace App\Livewire\SharedVariables; +use App\Services\SharedVariableCanvas; +use Illuminate\View\View; use Livewire\Component; class Index extends Component { - public function render() + public function render(): View { - return view('livewire.shared-variables.index'); + // A "my team" page: the overview of the session team's shared variables (keys and counts only). + return view('livewire.shared-variables.index', [ + 'overview' => app(SharedVariableCanvas::class)->overview(currentTeam()), + ]); } } diff --git a/app/Services/ContainerFilesystemService.php b/app/Services/ContainerFilesystemService.php index b5e2766257..e7178ed765 100644 --- a/app/Services/ContainerFilesystemService.php +++ b/app/Services/ContainerFilesystemService.php @@ -388,9 +388,7 @@ class ContainerFilesystemService */ public function buildS3UploadCommand(string $path, bool $isDir, string $url, array $headers): string { - $headerArgs = collect($headers) - ->map(fn (string $value, string $name) => '-H '.escapeshellarg("{$name}: {$value}")) - ->implode(' '); + $headerArgs = FileDownloadStorageService::curlHeaderArgs($headers); $script = 'tmp=$(mktemp /tmp/coolify-download-XXXXXXXXXX) || exit 1; ' .'trap \'rm -f "$tmp"\' EXIT; ' .$this->buildDownloadCommand($path, $isDir).' > "$tmp" && ' diff --git a/app/Services/EnvironmentCanvas.php b/app/Services/EnvironmentCanvas.php index ff42ebf6bd..500085b923 100644 --- a/app/Services/EnvironmentCanvas.php +++ b/app/Services/EnvironmentCanvas.php @@ -108,6 +108,8 @@ class EnvironmentCanvas $servers->put($server->id, $server); } unset($node['servers']); + // Other canvases keep the arrangement saved on the environment canvas inside each environment group. + $node['saved'] = $this->filterPositions($resource->environment?->canvas_positions ?? [], [$resource->uuid])[$resource->uuid] ?? null; return $node; })->values(); @@ -157,19 +159,19 @@ class EnvironmentCanvas $project = $environment->project; $environment->setRelation('project', $project); $applications = $environment->applications() - ->with(['destination.server.settings', 'additional_servers.settings', 'persistentStorages']) + ->with(['destination.server.settings', 'additional_servers.settings']) ->get(); $services = $environment->services() ->with([ 'server.settings', 'destination.server.settings', - 'applications.persistentStorages', - 'databases.persistentStorages', + 'applications', + 'databases', ]) ->get(); $databases = collect(self::DATABASE_RELATIONS) ->flatMap(fn (string $relation) => $environment->{$relation}() - ->with(['destination.server.settings', 'persistentStorages']) + ->with(['destination.server.settings']) ->get()); return collect() @@ -251,7 +253,6 @@ class EnvironmentCanvas 'kind' => $buildPack, 'subtitle' => $this->subtitle([self::BUILD_PACK_NAMES[$buildPack] ?? str($buildPack)->headline()->value(), $detail]), 'children' => [], - 'volumes' => $application->persistentStorages->pluck('name')->filter()->values()->all(), 'link' => $application->link(), 'logo' => resource_logo($application), 'servers' => collect([$server])->concat($extraServers)->filter()->all(), @@ -285,12 +286,6 @@ class EnvironmentCanvas 'kind' => null, 'subtitle' => $this->subtitle(['Service', $containerCount.' '.str('container')->plural($containerCount)]), 'children' => $children->all(), - 'volumes' => $service->applications->concat($service->databases) - ->flatMap(fn ($child) => $child->persistentStorages->pluck('name')) - ->filter() - ->unique() - ->values() - ->all(), 'link' => $service->link(), 'logo' => resource_logo($service), 'servers' => collect([$server])->filter()->all(), @@ -316,7 +311,6 @@ class EnvironmentCanvas 'kind' => $engine, 'subtitle' => $this->subtitle([self::DATABASE_ENGINE_NAMES[$engine] ?? str($engine)->headline()->value(), $database->image]), 'children' => [], - 'volumes' => $database->persistentStorages->pluck('name')->filter()->values()->all(), 'link' => $database->link(), 'logo' => resource_logo($database), 'servers' => collect([$server])->filter()->all(), diff --git a/app/Services/FileDownloadStorageService.php b/app/Services/FileDownloadStorageService.php index 000453b786..f342873c9c 100644 --- a/app/Services/FileDownloadStorageService.php +++ b/app/Services/FileDownloadStorageService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\S3Storage; +use App\Models\Server; use App\Rules\SafeWebhookUrl; use Aws\S3\Exception\S3Exception; use Aws\S3\S3Client; @@ -156,6 +157,47 @@ class FileDownloadStorageService $storage->filesystem()->delete($key); } + /** + * Size in bytes of a file on the server host (for example a backup), or + * null when the file does not exist. + */ + public static function hostFileSize(Server $server, string $path): ?int + { + $size = trim((string) instant_remote_process([self::buildHostFileSizeCommand($path)], $server)); + + return ctype_digit($size) ? (int) $size : null; + } + + public static function buildHostFileSizeCommand(string $path): string + { + validateShellSafePath($path, 'download path'); + $escaped = escapeshellarg($path); + + return 'sh -c '.escapeshellarg('if [ -f '.$escaped.' ]; then stat -L -c %s '.$escaped.'; fi'); + } + + /** + * PUT a file on the server host to a presigned S3 URL. + * + * @param array $headers + */ + public static function buildHostFileUploadCommand(string $path, string $url, array $headers): string + { + validateShellSafePath($path, 'download path'); + + return 'sh -c '.escapeshellarg('curl -fsS --retry 2 '.self::curlHeaderArgs($headers).' -T '.escapeshellarg($path).' '.escapeshellarg($url)); + } + + /** + * @param array $headers + */ + public static function curlHeaderArgs(array $headers): string + { + return collect($headers) + ->map(fn (string $value, string $name) => '-H '.escapeshellarg("{$name}: {$value}")) + ->implode(' '); + } + public static function contentDisposition(string $name): string { $fallback = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?: 'download'; diff --git a/app/Services/ProjectCanvas.php b/app/Services/ProjectCanvas.php index 20e537a8c4..a8a56f2d08 100644 --- a/app/Services/ProjectCanvas.php +++ b/app/Services/ProjectCanvas.php @@ -19,13 +19,13 @@ class ProjectCanvas /** * The canvas of every project of a team: one zone per project, cards grouped by environment. * - * @return array{title: string, title_link: string, can_edit: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array} + * @return array{title: string, title_link: string, can_edit: bool, can_move: false, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array{}} */ public function teamPayload(int $teamId, bool $canReadSensitive, bool $canEdit = false): array { $projects = Project::query()->where('team_id', $teamId)->orderBy('name')->get(['id', 'uuid', 'name', 'team_id']); $environments = Environment::query()->whereIn('project_id', $projects->pluck('id')) - ->get(['id', 'uuid', 'name', 'project_id', 'canvas_positions']); + ->get(['id', 'uuid', 'name', 'project_id']); $resources = $this->servers->loadResources($environments->pluck('id')); $links = ResourceLink::query()->whereIn('environment_id', $environments->pluck('id'))->orderBy('id')->get(); $graph = $this->canvas->graph($resources, $links, $canReadSensitive); @@ -42,6 +42,8 @@ class ProjectCanvas 'title' => 'Projects', 'title_link' => route('project.index'), 'can_edit' => $canEdit, + // Environment groups are auto-placed; cards inside a group keep their environment canvas arrangement. + 'can_move' => false, // Same grouped layout as the server canvas, with projects as the zones. 'layout' => 'server', 'zones' => $projects->map(fn (Project $project) => [ @@ -52,19 +54,16 @@ class ProjectCanvas ])->values()->all(), 'nodes' => collect($graph['nodes'])->map(fn (array $node) => [...$node, ...$grouping->get($node['id'])])->values()->all(), 'links' => $graph['links'], - 'positions' => $this->canvas->filterPositions( - $environments->reduce(fn (array $positions, Environment $environment) => [...$positions, ...($environment->canvas_positions ?? [])], []), - $resources->keys()->all(), - ), + 'positions' => [], ]; } /** - * @return array{title: string, title_link: string, can_edit: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array} + * @return array{title: string, title_link: string, can_edit: bool, can_move: false, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array{}} */ public function payload(Project $project, bool $canReadSensitive, bool $canEdit = false): array { - $environments = $project->environments()->get(['id', 'uuid', 'name', 'project_id', 'canvas_positions']); + $environments = $project->environments()->get(['id', 'uuid', 'name', 'project_id']); $resources = $this->servers->loadResources($environments->pluck('id')); $links = ResourceLink::query()->whereIn('environment_id', $environments->pluck('id'))->orderBy('id')->get(); $graph = $this->canvas->graph($resources, $links, $canReadSensitive); @@ -79,20 +78,18 @@ class ProjectCanvas 'title' => $project->name, 'title_link' => route('project.show', ['project_uuid' => $project->uuid]), 'can_edit' => $canEdit, + 'can_move' => false, // Groups cards by environment inside each server zone, like the server canvas. 'layout' => 'server', 'zones' => $graph['zones'], 'nodes' => collect($graph['nodes'])->map(fn (array $node) => [...$node, ...$context->get($node['id'])])->values()->all(), 'links' => $graph['links'], - 'positions' => $this->canvas->filterPositions( - $environments->reduce(fn (array $positions, Environment $environment) => [...$positions, ...($environment->canvas_positions ?? [])], []), - $resources->keys()->all(), - ), + 'positions' => [], ]; } /** - * Project and environment of a card: the canvas saves moves and links on that environment canvas. + * Project and environment of a card: the canvas saves links on that environment canvas. * * @return array{project: array{uuid: string, name: string}, environment: array{uuid: string, name: string}} */ diff --git a/app/Services/ServerCanvas.php b/app/Services/ServerCanvas.php index 061bd2ad2c..aa69c51ad6 100644 --- a/app/Services/ServerCanvas.php +++ b/app/Services/ServerCanvas.php @@ -2,18 +2,22 @@ namespace App\Services; +use App\Enums\ApplicationDeploymentStatus; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; use App\Models\ResourceLink; use App\Models\Server; use App\Models\Service; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; /** * Builds the server canvas: one zone per server with every resource of the team on it, - * in the same shape as the environment canvas. Cards keep the positions and links saved on their environment canvas; - * admins change them through the environment canvas endpoints of each card. + * in the same shape as the environment canvas. Cards and project / environment groups use the positions saved on + * their environment canvas (node.saved); admins move whole groups here, which saves the moved cards on that environment + * canvas, and change links through the environment canvas endpoints of each card. */ class ServerCanvas { @@ -21,15 +25,15 @@ class ServerCanvas /** * @param Collection $servers servers of the team to show - * @return array{title: string, title_link: ?string, can_edit: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array} + * @return array{title: string, title_link: ?string, can_edit: bool, can_move: false, can_move_groups: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array{}} */ public function payload(int $teamId, Collection $servers, ?Server $single, bool $canReadSensitive, bool $canEdit = false): array { - $environments = Environment::query() + $environmentIds = Environment::query() ->whereHas('project', fn ($query) => $query->where('team_id', $teamId)) - ->get(['id', 'canvas_positions']); - $resources = $this->loadResources($environments->pluck('id')); - $links = ResourceLink::query()->whereIn('environment_id', $environments->pluck('id'))->orderBy('id')->get(); + ->pluck('id'); + $resources = $this->loadResources($environmentIds); + $links = ResourceLink::query()->whereIn('environment_id', $environmentIds)->orderBy('id')->get(); $graph = $this->canvas->graph($resources, $links, $canReadSensitive); $serverUuids = $servers->pluck('uuid')->all(); @@ -59,16 +63,121 @@ class ServerCanvas // Unmanaged containers need a command on the server, so the canvas loads them after it renders. 'containers_endpoint' => $single ? '/servers/'.$single->uuid.'/canvas/containers' : null, 'can_edit' => $canEdit, + 'can_move' => false, + // Admins drag the project / environment group boxes; the canvas saves the moved cards on their environment canvas. + 'can_move_groups' => $canEdit, // Tells the canvas to group cards by project / environment and place linked cards side by side. 'layout' => 'server', - 'zones' => $servers->map(fn (Server $server) => $this->canvas->zone($server, $canReadSensitive))->values()->all(), + 'zones' => $servers->map(fn (Server $server) => [...$this->canvas->zone($server, $canReadSensitive), 'details' => $this->details($server)])->values()->all(), 'nodes' => $nodes->all(), 'links' => collect($graph['links'])->filter(fn (array $link) => $shown->has($link['source']) && $shown->has($link['target']))->values()->all(), - // Card positions saved on the environment canvases; the canvas keeps their arrangement per project / environment group. - 'positions' => $this->canvas->filterPositions( - $environments->reduce(fn (array $positions, Environment $environment) => [...$positions, ...($environment->canvas_positions ?? [])], []), - $nodes->pluck('id')->all(), - ), + 'positions' => [], + ]; + } + + /** + * Host facts and proxy of a server for its zone header. Live usage loads after the canvas renders + * (usage_endpoint), because it runs a command on the server. + * + * @return array{os: ?string, arch: ?string, cpus: ?int, memory_bytes: ?int, uptime_since: ?string, proxy: ?array{type: string, status: ?string}, usage_endpoint: ?string} + */ + public function details(Server $server): array + { + $meta = $server->server_metadata ?? []; + $proxyType = $server->proxyType(); + $live = $server->settings?->is_reachable && $server->settings?->is_metrics_enabled; + + return [ + 'os' => $meta['os'] ?? null, + 'arch' => $meta['arch'] ?? null, + 'cpus' => isset($meta['cpus']) ? (int) $meta['cpus'] : null, + 'memory_bytes' => isset($meta['memory_bytes']) ? (int) $meta['memory_bytes'] : null, + 'uptime_since' => $meta['uptime_since'] ?? null, + 'proxy' => $proxyType && $proxyType !== 'NONE' ? ['type' => $proxyType, 'status' => data_get($server->proxy, 'status')] : null, + 'usage_endpoint' => $live ? '/servers/'.$server->uuid.'/canvas/usage' : null, + ]; + } + + /** + * Latest CPU, memory and root disk usage in percent from Sentinel, cached briefly so that + * canvas refreshes do not run a command on the server each time. Null values: no data. + * + * @return array{cpu: ?float, memory: ?float, disk: ?float} + */ + public function usage(Server $server): array + { + return Cache::remember("server-canvas-usage:{$server->id}", 30, function () use ($server) { + $latest = function (callable $read): ?float { + try { + $value = data_get(last($read() ?: [null]), 1); + + return is_numeric($value) ? round((float) $value, 1) : null; + } catch (\Throwable) { + return null; + } + }; + + return [ + 'cpu' => $latest(fn () => $server->getCpuMetrics(1)), + 'memory' => $latest(fn () => $server->getMemoryMetrics(1)), + 'disk' => $latest(fn () => $server->getDiskMetrics(1)), + ]; + }); + } + + /** + * Dashboard canvas: the server canvas of the team, on each application card its + * running or queued deployment (the oldest first, as the deployment queue runs them), + * and the list of active and recent deployments. + * + * @param Collection $servers servers of the team to show + * @return array + */ + public function dashboardPayload(int $teamId, Collection $servers, bool $canReadSensitive, bool $canEdit = false): array + { + $payload = $this->payload($teamId, $servers, null, $canReadSensitive, $canEdit); + + $columns = ['deployment_uuid', 'application_id', 'application_name', 'status', 'deployment_url', 'pull_request_id', 'server_name', 'created_at', 'finished_at']; + $query = fn () => ApplicationDeploymentQueue::query()->whereIn('server_id', $servers->pluck('id')); + $activeStatuses = [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value]; + $active = $query()->whereIn('status', $activeStatuses)->orderBy('status')->orderBy('id')->get($columns); + $recent = $query()->whereNotIn('status', $activeStatuses)->orderByDesc('id')->limit(8)->get($columns); + $applicationUuids = Application::query() + ->whereIn('id', $active->concat($recent)->pluck('application_id')->unique()) + ->pluck('uuid', 'id'); + $uuidOf = fn (ApplicationDeploymentQueue $deployment) => $applicationUuids->get((int) $deployment->application_id); + $summary = fn (ApplicationDeploymentQueue $deployment) => [ + 'status' => $deployment->status, + 'link' => $deployment->deployment_url, + 'pull_request_id' => (int) $deployment->pull_request_id ?: null, + ]; + $byUuid = $active + ->filter($uuidOf) + ->unique('application_id') + ->mapWithKeys(fn (ApplicationDeploymentQueue $deployment) => [$uuidOf($deployment) => $summary($deployment)]); + + return [ + ...$payload, + 'title' => 'Dashboard', + 'title_link' => route('dashboard'), + 'nodes' => collect($payload['nodes']) + ->map(fn (array $node) => $node['type'] === 'application' && $byUuid->has($node['id']) + ? [...$node, 'deployment' => $byUuid->get($node['id'])] + : $node) + ->all(), + // The deployment panel: running and queued deployments first, then the latest finished ones. + 'deployments' => $active->concat($recent) + ->map(fn (ApplicationDeploymentQueue $deployment) => [ + 'id' => $deployment->deployment_uuid, + 'application' => $uuidOf($deployment), + 'name' => $deployment->application_name, + 'server' => $deployment->server_name, + 'created_at' => $deployment->created_at?->toIso8601String(), + 'finished_at' => $deployment->finished_at?->toIso8601String(), + ...$summary($deployment), + ]) + ->values() + ->all(), ]; } @@ -101,7 +210,6 @@ class ServerCanvas 'urls' => [], 'subtitle' => (string) data_get($container, 'Image'), 'children' => [], - 'volumes' => [], 'link' => route('server.resources', ['server_uuid' => $server->uuid]), 'context' => 'Unmanaged containers', 'context_link' => route('server.resources', ['server_uuid' => $server->uuid]), @@ -120,21 +228,21 @@ class ServerCanvas public function loadResources(Collection $environmentIds): Collection { $applications = Application::query()->whereIn('environment_id', $environmentIds) - ->with(['environment.project', 'destination.server.settings', 'additional_servers.settings', 'persistentStorages']) + ->with(['environment.project', 'destination.server.settings', 'additional_servers.settings']) ->get(); $services = Service::query()->whereIn('environment_id', $environmentIds) ->with([ 'environment.project', 'server.settings', 'destination.server.settings', - 'applications.persistentStorages', - 'databases.persistentStorages', + 'applications', + 'databases', ]) ->get(); $databases = collect(EnvironmentCanvas::DATABASE_RELATIONS) ->flatMap(fn (string $relation) => (new Environment)->{$relation}()->getRelated()->newQuery() ->whereIn('environment_id', $environmentIds) - ->with(['environment.project', 'destination.server.settings', 'persistentStorages']) + ->with(['environment.project', 'destination.server.settings']) ->get()); return collect()->concat($applications)->concat($services)->concat($databases)->keyBy('uuid'); diff --git a/app/Services/SharedVariableCanvas.php b/app/Services/SharedVariableCanvas.php index 4e35da400a..3598848ed9 100644 --- a/app/Services/SharedVariableCanvas.php +++ b/app/Services/SharedVariableCanvas.php @@ -240,6 +240,81 @@ class SharedVariableCanvas return 'variable:'.$variable->id; } + /** + * The shared variables overview of a team: counts per scope, recently changed variables, + * and how many resources use each variable (from the {{scope.KEY}} references in the + * saved resource variables). Keys and counts only; values are never read out. + * + * @return array{scopes: array, recent: Collection>, used: Collection>, unused: Collection>, missing: int} + */ + public function overview(Team $team): array + { + $variables = SharedEnvironmentVariable::query() + ->where('team_id', $team->id) + ->with(['project', 'environment.project', 'server']) + ->get() + // Built-in server variables; the server variables page hides them too. + ->reject(fn (SharedEnvironmentVariable $variable) => $variable->type === 'server' && in_array($variable->key, ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'], true)); + + $scopes = collect(self::SCOPES)->mapWithKeys(function (string $scope) use ($variables) { + $ofScope = $variables->where('type', $scope); + $ownerColumn = $scope === 'team' ? 'team_id' : $scope.'_id'; + + return [$scope => ['variables' => $ofScope->count(), 'owners' => $ofScope->pluck($ownerColumn)->unique()->count()]]; + })->all(); + + // Resources per variable: "scope|owner id|KEY" => resource uuids. + $idByReference = $variables->mapWithKeys(fn (SharedEnvironmentVariable $variable) => [ + $variable->type.'|'.($variable->type === 'team' ? $variable->team_id : $variable->{$variable->type.'_id'}).'|'.$variable->key => $variable->id, + ]); + $resources = $this->resources('team', $team); + $resourceByMorph = $resources->mapWithKeys(fn (Model $resource) => [$resource->getMorphClass().'#'.$resource->getKey() => $resource]); + $usedBy = []; + $missing = []; + foreach ($this->resourceVariables($resources) as $environmentVariable) { + $resource = $resourceByMorph->get($environmentVariable->resourceable_type.'#'.$environmentVariable->resourceable_id); + $owners = [ + 'team' => $team->id, + 'project' => $resource->environment->project_id, + 'environment' => $resource->environment_id, + 'server' => $this->mainServer($resource)?->id, + ]; + foreach ($owners as $scope => $ownerId) { + foreach ($this->referencedKeys($scope, $environmentVariable->value) as $key) { + $reference = $scope.'|'.$ownerId.'|'.$key; + if ($idByReference->has($reference)) { + $usedBy[$idByReference->get($reference)][$resource->uuid] = true; + } else { + $missing[$reference] = true; + } + } + } + } + + $rows = $variables->map(fn (SharedEnvironmentVariable $variable) => [ + 'key' => $variable->key, + 'scope' => $variable->type, + 'owner' => match ($variable->type) { + 'team' => 'Team', + 'project' => $variable->project?->name, + 'environment' => $variable->environment ? $variable->environment->project?->name.' / '.$variable->environment->name : null, + 'server' => $variable->server?->name, + default => null, + }, + 'link' => $this->connectLink($variable), + 'resources' => count($usedBy[$variable->id] ?? []), + 'updated_at' => $variable->updated_at, + ]); + + return [ + 'scopes' => $scopes, + 'recent' => $rows->sortByDesc('updated_at')->take(6)->values(), + 'used' => $rows->where('resources', '>', 0)->sortByDesc('resources')->take(6)->values(), + 'unused' => $rows->where('resources', 0)->sortBy('key')->values(), + 'missing' => count($missing), + ]; + } + /** * The team of the scope owner. */ diff --git a/app/Traits/HasS3Replicas.php b/app/Traits/HasS3Replicas.php index 81e935e174..0d63823cd6 100644 --- a/app/Traits/HasS3Replicas.php +++ b/app/Traits/HasS3Replicas.php @@ -2,6 +2,7 @@ namespace App\Traits; +use App\Services\FileDownloadStorageService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -44,6 +45,47 @@ trait HasS3Replicas return $this->s3Replicas()->where('s3_uploaded', true)->where('s3_storage_deleted', false)->exists(); } + /** + * Whether a local file or a live S3 copy is left to download. + */ + public function isDownloadable(): bool + { + return ! $this->local_storage_deleted + || $this->s3Replicas->contains(fn ($replica): bool => $replica->s3_uploaded === true && $replica->s3_storage_deleted === false); + } + + /** + * A short-lived presigned download URL for the first live copy in a usable S3 storage of the given team, the + * primary storage first. Null when no such copy exists. + */ + public function s3DownloadUrl(int $teamId, ?int $primaryStorageId): ?string + { + if (blank($this->filename)) { + return null; + } + + $replica = $this->s3Replicas() + ->with('s3') + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false) + ->whereHas('s3', fn (Builder $query) => $query->where('team_id', $teamId)->where('is_usable', true)) + ->orderBy('id') + ->get() + ->sortByDesc(fn ($replica): bool => $replica->s3_storage_id === $primaryStorageId) + ->first(); + + if (! $replica) { + return null; + } + + // The upload copies the file to "/", so the object key is the local file path. + return $replica->s3->filesystem()->temporaryUrl( + $this->filename, + now()->addMinutes(FileDownloadStorageService::LINK_TTL_MINUTES), + ['ResponseContentDisposition' => FileDownloadStorageService::contentDisposition(basename($this->filename))], + ); + } + /** * Writes the replica states to the summary columns: uploaded when every copy uploaded, deleted when every uploaded * copy is deleted. diff --git a/app/Traits/PreparesBackupDownloads.php b/app/Traits/PreparesBackupDownloads.php new file mode 100644 index 0000000000..118813fa5f --- /dev/null +++ b/app/Traits/PreparesBackupDownloads.php @@ -0,0 +1,82 @@ + */ + public array $pendingDownloads = []; + + /** + * Authorize the user and load the backup from a record scoped to the + * component before you call this. Pass a null server or file name when + * the local file is gone; the download route then answers the request. + */ + protected function prepareBackupDownload(?Server $server, ?string $filename, string $streamUrl): void + { + $storage = FileDownloadStorageService::configuredStorage(); + $size = $storage && $server && filled($filename) ? FileDownloadStorageService::hostFileSize($server, $filename) : null; + if ($size === null || $size > FileDownloadStorageService::MAX_SIZE) { + $this->dispatch('open-backup-download', url: $streamUrl); + + return; + } + + if (count($this->pendingDownloads) >= self::MAX_PENDING_S3_DOWNLOADS + || ! RateLimiter::attempt('file-browser-s3-download:'.auth()->id(), 10, fn () => true, 3600)) { + $this->dispatch('error', 'Too many downloads are in progress. Wait for them to finish, then try again.'); + + return; + } + + $downloadId = (string) Str::uuid(); + $this->pendingDownloads[] = $downloadId; + PrepareFileDownloadJob::dispatch($downloadId, auth()->id(), $server->id, null, $filename, false, basename($filename), $storage->id); + $this->dispatch('info', 'Preparing '.basename($filename).'. The download starts when it is ready.'); + } + + /** + * Polled while S3 downloads are prepared. Opens each finished link once. + */ + public function checkDownloads(): void + { + foreach ($this->pendingDownloads as $index => $downloadId) { + $result = Cache::get(PrepareFileDownloadJob::cacheKey($downloadId)); + if (! is_array($result) || ($result['user_id'] ?? null) !== auth()->id()) { + continue; + } + + unset($this->pendingDownloads[$index]); + Cache::forget(PrepareFileDownloadJob::cacheKey($downloadId)); + if ($result['status'] === 'ready') { + $this->dispatch('open-backup-download', url: $result['url']); + } else { + $this->dispatch('error', $result['message']); + } + } + $this->pendingDownloads = array_values($this->pendingDownloads); + } + + protected function s3BackupDownloadsEnabled(): bool + { + return FileDownloadStorageService::configuredStorage() !== null; + } +} diff --git a/config/constants.php b/config/constants.php index ed674133e8..7b06283559 100644 --- a/config/constants.php +++ b/config/constants.php @@ -5,7 +5,7 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600); return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.4.6', + 'version' => env('COOLIFY_VERSION') ?: '4.5', 'helper_version' => '1.0.18', 'railpack_version' => '0.40.1', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/docker-compose.dev-multi.yml b/docker-compose.dev-multi.yml index 153e1c9a1b..1b5e3fad10 100644 --- a/docker-compose.dev-multi.yml +++ b/docker-compose.dev-multi.yml @@ -181,7 +181,10 @@ services: volumes: - minio_data:/data networks: - - coolify + coolify: + # The seeded "Local MinIO" storage (S3StorageSeeder) uses http://coolify-minio:9000. + aliases: + - coolify-minio minio-init: profiles: ["minio"] diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 4165d6831e..7dc784d8e6 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.4.6" + "version": "4.5" }, "nightly": { - "version": "4.5-rc.1" + "version": "4.6-rc.1" }, "helper": { "version": "1.0.18" diff --git a/resources/css/app.css b/resources/css/app.css index 839a8514af..730a982b7f 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1889,6 +1889,15 @@ html[data-theme="custom"] textarea:disabled { grid-column: 2; } + /* A resource menu that the main sidebar shows instead (navbar #main-nav-context). */ + .application-settings-navigation.in-main-nav { + display: none; + } + + .application-settings-navigation.in-main-nav ~ * { + grid-column: 1 / -1; + } + html:not(.dark) .application-settings-navigation { --rail-fill: var(--coollabs-base); } @@ -1916,7 +1925,7 @@ html[data-theme="custom"] textarea:disabled { z-index: 20; } - .app-main-full .application-settings-navigation ~ [data-environment-canvas] { + .app-main-full .application-settings-navigation:not(.in-main-nav) ~ [data-environment-canvas] { margin-left: calc(210px + 2.5rem); } @@ -2035,14 +2044,14 @@ html[data-theme="custom"] textarea:disabled { background: transparent; } -/* Main sidebar: the menu and footer scroll as one unit on short or zoomed-in - screens. Hide the scrollbar so it does not take width from the narrow - collapsed rail and push the icons aside. */ -.sidebar-nav { +/* Main sidebar: only the menu scrolls on short or zoomed-in screens; the + footer stays visible. Hide the scrollbar so it does not take width from the + narrow collapsed rail and push the icons aside. */ +.sidebar-menu { scrollbar-width: none; /* Firefox */ } -.sidebar-nav::-webkit-scrollbar { +.sidebar-menu::-webkit-scrollbar { display: none; /* Chrome/Safari */ } @@ -2175,7 +2184,7 @@ html[data-theme="custom"] textarea:disabled { .application-heading-actions .button, .application-heading-actions .app-tab, .application-heading-actions .relative > button[x-ref='trigger'] { - height: 1.75rem; + height: 2rem; display: inline-flex; align-items: center; gap: 0.375rem; @@ -2332,11 +2341,6 @@ html[data-theme="custom"] textarea:disabled { box-shadow: 0 0 0 1px var(--color-accent); } -.application-heading-actions .split-action-main, -.application-heading-actions .split-action-caret { - height: 1.75rem; -} - /* Custom listbox (replaces native - - - - @if (auth()->user()->avatar_path) - Remove - @endif - -

- @error('avatar') -

{{ $message }}

- @enderror - - -
@@ -126,10 +15,117 @@

Profile details

-

Your display name and verified sign-in address.

+

Your avatar, display name, and verified sign-in address.

+
+
Avatar
+
+
+ Profile picture preview + @if (auth()->user()->avatar_path) + {{ auth()->user()->name }} + @else + + {{ strtoupper(mb_substr(auth()->user()->name ?: auth()->user()->email, 0, 1)) }} + + @endif +
+
+
+ + + + + @if (auth()->user()->avatar_path) + Remove + @endif +
+

JPG, PNG, or WebP. Shown in the user menu and team member lists.

+

+ @error('avatar') +

{{ $message }}

+ @enderror +
+
+
diff --git a/resources/views/livewire/project/application/backup/show.blade.php b/resources/views/livewire/project/application/backup/show.blade.php index 982160794d..a5a8e84fae 100644 --- a/resources/views/livewire/project/application/backup/show.blade.php +++ b/resources/views/livewire/project/application/backup/show.blade.php @@ -11,7 +11,7 @@
diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 168b102e0c..e223ed8123 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -30,8 +30,6 @@ @elseif ($currentRoute === 'project.application.scheduled-tasks.show') - @elseif ($currentRoute === 'project.application.scheduled-tasks') - @elseif ($currentRoute === 'project.application.webhooks') @elseif ($currentRoute === 'project.application.preview-deployments') diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index d85d506a0b..90543e817b 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -1,13 +1,5 @@
- @if ($backup->database_id === 0) - @include('livewire.project.database.backup-edit.general') - @include('livewire.project.database.backup-edit.s3') - @include('livewire.project.database.backup-edit.retention') - @elseif ($section === 'retention') - @include('livewire.project.database.backup-edit.retention') - @elseif ($section === 's3') - @include('livewire.project.database.backup-edit.s3') - @elseif ($section === 'danger') + @if ($section === 'danger' && $backup->database_id !== 0) @include('livewire.project.database.backup-edit.danger') @else @include('livewire.project.database.backup-edit.general') diff --git a/resources/views/livewire/project/database/backup-edit/general.blade.php b/resources/views/livewire/project/database/backup-edit/general.blade.php index 49385b0258..dfe465e07a 100644 --- a/resources/views/livewire/project/database/backup-edit/general.blade.php +++ b/resources/views/livewire/project/database/backup-edit/general.blade.php @@ -1,10 +1,11 @@ - + -
+ {{-- Below xl the header bar is hidden, so keep the buttons here. --}} +
$actionsInHeader])> @if (! $backupEnabled) @@ -98,4 +99,7 @@
+ + @include('livewire.project.database.backup-edit.s3') + @include('livewire.project.database.backup-edit.retention') diff --git a/resources/views/livewire/project/database/backup-edit/retention.blade.php b/resources/views/livewire/project/database/backup-edit/retention.blade.php index 8825b3c26d..d340216537 100644 --- a/resources/views/livewire/project/database/backup-edit/retention.blade.php +++ b/resources/views/livewire/project/database/backup-edit/retention.blade.php @@ -1,34 +1,30 @@ -
- - - -
-
-

Local backups

-
- - - -
-
- -
-

S3 backups

-
- - - -
+ +
+
+

Local backups

+
+ + +
- - + +
+

S3 backups

+
+ + + +
+
+
+
diff --git a/resources/views/livewire/project/database/backup-edit/s3.blade.php b/resources/views/livewire/project/database/backup-edit/s3.blade.php index 4f0f7b43fd..586c3617cf 100644 --- a/resources/views/livewire/project/database/backup-edit/s3.blade.php +++ b/resources/views/livewire/project/database/backup-edit/s3.blade.php @@ -10,33 +10,29 @@ @else -
- - - - - @if (! $saveS3) - Enable S3 - @else - Disable S3 - @endif - -
- - -
-
- + + + @if (! $saveS3) + Enable S3 + @else + Disable S3 + @endif + +
+ + +
+
@endif diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index f267908207..ff8083636e 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -1,4 +1,7 @@ -
+
+ @if (count($pendingDownloads) > 0) + + @endif @isset($backup)
@@ -197,7 +200,11 @@ @script @endscript diff --git a/resources/views/livewire/project/database/backup/execution.blade.php b/resources/views/livewire/project/database/backup/execution.blade.php index 911f403db8..19ef5948b1 100644 --- a/resources/views/livewire/project/database/backup/execution.blade.php +++ b/resources/views/livewire/project/database/backup/execution.blade.php @@ -5,6 +5,23 @@ + @can('manageBackups', $database) + @teleport('#resource-action-hud-slot') + + @endteleport + @endcan +
@@ -15,7 +32,7 @@ @else + :actions-in-header="true" wire:key="database-backup-{{ $backup->uuid }}-{{ $section }}" /> @endif
diff --git a/resources/views/livewire/project/environment-canvas.blade.php b/resources/views/livewire/project/environment-canvas.blade.php index 515a3de5d6..61d53cfdde 100644 --- a/resources/views/livewire/project/environment-canvas.blade.php +++ b/resources/views/livewire/project/environment-canvas.blade.php @@ -2,7 +2,7 @@ {{ $environment->name }} > Canvas | Coolify -
diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index a9fa3dc5f6..8b8ef4da9b 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -35,7 +35,7 @@ :aria-selected="resourceType === option.value" @click="resourceType = option.value; close()"> - @@ -71,7 +71,7 @@ :aria-selected="selectedCategory === ''" @click="selectedCategory = ''; categorySearch = ''; categoryOpen = false"> All categories - @@ -95,9 +95,9 @@ {{-- A full page load: the canvas uses the minimal layout. --}} + class="mt-6 flex items-center gap-4 rounded-lg border border-neutral-200 bg-white p-4 shadow-[0_1px_2px_rgb(0_0_0/0.05)] transition-colors hover:border-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:border-white/[0.08] dark:bg-white/[0.05] dark:shadow-none dark:hover:border-white/[0.14]">
+ class="flex size-11 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
diff --git a/resources/views/livewire/project/project-canvas.blade.php b/resources/views/livewire/project/project-canvas.blade.php index 86552e3c49..8083cdc921 100644 --- a/resources/views/livewire/project/project-canvas.blade.php +++ b/resources/views/livewire/project/project-canvas.blade.php @@ -2,7 +2,7 @@ {{ $project->name }} > Canvas | Coolify -
Projects > Canvas | Coolify -
+
+ @if (count($pendingDownloads) > 0) + + @endif @if ($selectedExecution) @@ -60,7 +63,12 @@ {{ $execution['started_at']->diffForHumans() }} {{ $execution['size'] ? formatBytes($execution['size']) : '-' }} - @if ($execution['download_url']) + @if ($execution['download_url'] && $s3Downloads) + + @elseif ($execution['download_url']) diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index 0cc231603a..dfad82ca14 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -26,7 +26,7 @@ ['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'], ['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'], ['label' => 'Tags', 'route' => 'project.service.tags', 'icon' => 'tags'], - ['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'shield-alert'], + ['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'danger'], ])->filter(fn (array $item): bool => $item['visible'] ?? true)->map(fn (array $item): array => [ ...$item, 'active' => $currentRoute === $item['route'] @@ -38,7 +38,9 @@ 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'], 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Analytics'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'], - 'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'], + 'Operations' => ['Resource Operations', 'Tags'], + // Always last, after a divider and without a group header. + 'Danger Zone' => ['Danger Zone'], ]; $groupedItems = collect($menuGroups) @@ -61,7 +63,7 @@
-
@endunless + @unless ($groupLabel === 'Danger Zone') -
+ @endunless +
$groupLabel === 'Danger Zone']) @unless ($groupLabel === 'Danger Zone') :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'" @endunless> @foreach ($groupItems as $menuItem) @endforeach - +
@if ($currentRoute === 'project.service.configuration') @@ -132,25 +136,7 @@

@elseif ($currentRoute === 'project.service.scheduled-tasks.show') - @elseif ($currentRoute === 'project.service.scheduled-tasks') - @elseif ($currentRoute === 'project.service.webhooks') @elseif ($currentRoute === 'project.service.resource-operations') diff --git a/resources/views/livewire/project/service/index.blade.php b/resources/views/livewire/project/service/index.blade.php index 268eaae499..76e9cb4c20 100644 --- a/resources/views/livewire/project/service/index.blade.php +++ b/resources/views/livewire/project/service/index.blade.php @@ -7,11 +7,11 @@ @if (! $embedded && $resourceType === 'database') @elseif (! $embedded) - + @endif
@if ($resourceType === 'application') diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index acde772a26..ddd7228de2 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -76,7 +76,7 @@ - @foreach (['general', 's3', 'retention', 'danger'] as $modalSection) + @foreach (['general', 'danger'] as $modalSection)
@if ($selectedDatabaseBackup) -
-
- @if ($type === 'application') - - @elseif ($type === 'service') - - @else - - @endif -
+
+ @if ($type === 'application') + + @elseif ($type === 'service') + + @else + + @endif
+ @if ($selectedTask) + + + + + + @endif + @@ -32,13 +41,10 @@ 'running' => 'Running', default => 'Not run yet', }; - $taskRoute = $resource->type() === 'application' - ? 'project.application.scheduled-tasks' - : 'project.service.scheduled-tasks'; @endphp - + @empty -
+
+ + + - @if ($resource->isRunning()) @can('update', $resource) @@ -16,13 +28,6 @@ {{ $isEnabled ? 'Disable' : 'Enable' }} - @endcan @@ -47,8 +52,28 @@ - + + @can('update', $resource) +
+ + +

The task stops running and cannot be restored.

+ + + +
+
+
+ @endcan
diff --git a/resources/views/livewire/project/shared/storages/volume-backups.blade.php b/resources/views/livewire/project/shared/storages/volume-backups.blade.php index cc354e88d5..052aa48f5e 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups.blade.php @@ -1,9 +1,22 @@ -
- @if ($section === 'retention') - @include('livewire.project.shared.storages.volume-backups.retention') - @elseif ($section === 's3') - @include('livewire.project.shared.storages.volume-backups.s3') - @elseif ($section === 'executions') +
+ @if (count($pendingDownloads) > 0) + + @endif + @if ($actionsInHeader && $backup) + @can('update', $resource) + @teleport('#resource-action-hud-slot') + + @endteleport + @endcan + @endif + @if ($section === 'executions') @include('livewire.project.shared.storages.volume-backups.executions') @elseif ($section === 'danger') @include('livewire.project.shared.storages.volume-backups.danger') @@ -14,7 +27,11 @@ @script @endscript diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 041fb4470d..dabc86e905 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -137,7 +137,7 @@ - @if ($execution->status === 'success' && ! $execution->local_storage_deleted) + @if ($execution->status === 'success' && $execution->isDownloadable()) - -
-
+
diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 9567a3f7fa..28f419d03c 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -34,7 +34,7 @@ 'label' => 'Danger Zone', 'route' => 'source.github.danger', 'active' => $activeTab === 'danger', - 'icon' => 'shield-alert', + 'icon' => 'danger', ], ]; @endphp @@ -47,12 +47,14 @@ @if ($showSettingsSidebar)
- +
@if (!data_get($github_app, 'installation_id') && $activeTab === 'general') diff --git a/resources/views/livewire/storage/index.blade.php b/resources/views/livewire/storage/index.blade.php index 30f492705e..ddb23ab6ea 100644 --- a/resources/views/livewire/storage/index.blade.php +++ b/resources/views/livewire/storage/index.blade.php @@ -44,7 +44,7 @@ matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); }, setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-s3-storages-view', mode); } }"> - @include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages', 'singular' => 'storage', 'plural' => 'storages']) + @include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages'])
@foreach ($s3 as $storage) 'Danger Zone', 'route' => 'storage.danger', 'active' => $currentRoute === 'storage.danger', - 'icon' => 'shield-alert', + 'icon' => 'danger', ], ]; @endphp @@ -36,12 +36,14 @@ @if ($showSettingsSidebar)
- +
@if ($currentRoute === 'storage.show') diff --git a/resources/views/livewire/tags/canvas.blade.php b/resources/views/livewire/tags/canvas.blade.php index 3a01be7f6f..246addc1e6 100644 --- a/resources/views/livewire/tags/canvas.blade.php +++ b/resources/views/livewire/tags/canvas.blade.php @@ -2,7 +2,7 @@ Tags > Canvas | Coolify -
{{-- The canvas moves this group into its toolbar (page actions last, at the right end), diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index fc93d4a72c..2a3ebe330b 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -71,7 +71,7 @@ matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); }, setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-sources-view', mode); } }"> - @include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources', 'singular' => 'source', 'plural' => 'sources']) + @include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources'])
@foreach ($sources as $source) @if ($source->getMorphClass() === 'App\Models\GithubApp') diff --git a/routes/web.php b/routes/web.php index 4dde2c922f..a484892564 100644 --- a/routes/web.php +++ b/routes/web.php @@ -26,6 +26,7 @@ use App\Livewire\Admin\Index as AdminIndex; use App\Livewire\Analytics; use App\Livewire\Boarding\Index as BoardingIndex; use App\Livewire\Dashboard; +use App\Livewire\Dashboard\Canvas as DashboardCanvas; use App\Livewire\DeployCanvas; use App\Livewire\Destination\Index as DestinationIndex; use App\Livewire\Destination\Resources as DestinationResources; @@ -192,6 +193,7 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::get('/', Dashboard::class)->name('dashboard'); + Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); Route::get('/analytics', Analytics::class)->name('analytics'); Route::get('/metrics', FleetMetrics::class)->name('metrics'); Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); @@ -220,8 +222,10 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::delete('/shared-variables/{scope}/{uuid}/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); Route::get('/servers', [ServersController::class, 'servers'])->middleware(['api.ability:read']); Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['api.ability:read']); Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); @@ -387,8 +391,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/persistent-storage', ApplicationConfiguration::class)->name('project.application.persistent-storage'); Route::get('/backups', ApplicationBackupIndex::class)->name('project.application.backup.index'); Route::get('/backups/{backup_uuid}', ApplicationBackupShow::class)->name('project.application.backup.show'); - Route::get('/backups/{backup_uuid}/s3', ApplicationBackupShow::class)->name('project.application.backup.s3'); - Route::get('/backups/{backup_uuid}/retention', ApplicationBackupShow::class)->name('project.application.backup.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/application/{application_uuid}/backups/{backup_uuid}'); + Route::redirect('/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/application/{application_uuid}/backups/{backup_uuid}'); Route::get('/backups/{backup_uuid}/executions', ApplicationBackupShow::class)->name('project.application.backup.executions'); Route::get('/backups/{backup_uuid}/danger', ApplicationBackupShow::class)->name('project.application.backup.danger'); Route::get('/source', ApplicationConfiguration::class)->name('project.application.source'); @@ -410,7 +415,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/logs', Logs::class)->name('project.application.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.application.command')->middleware('can.access.terminal'); Route::get('/files', FileBrowser::class)->name('project.application.files')->middleware('can.access.terminal'); - Route::get('/tasks/{task_uuid}', ApplicationConfiguration::class)->name('project.application.scheduled-tasks'); + // Tasks open in a modal on the list page; keep old links (e.g. notifications) working. + Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.application.scheduled-tasks.show', get_route_parameters()))->name('project.application.scheduled-tasks'); }); Route::prefix('project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}')->group(function () { Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration'); @@ -431,8 +437,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/files', FileBrowser::class)->name('project.database.files')->middleware('can.access.terminal'); Route::get('/backups', DatabaseBackupIndex::class)->name('project.database.backup.index'); Route::get('/backups/{backup_uuid}', DatabaseBackupExecution::class)->name('project.database.backup.execution'); - Route::get('/backups/{backup_uuid}/s3', DatabaseBackupExecution::class)->name('project.database.backup.s3'); - Route::get('/backups/{backup_uuid}/retention', DatabaseBackupExecution::class)->name('project.database.backup.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}/backups/{backup_uuid}'); + Route::redirect('/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}/backups/{backup_uuid}'); Route::get('/backups/{backup_uuid}/executions', DatabaseBackupExecution::class)->name('project.database.backup.executions'); Route::get('/backups/{backup_uuid}/danger', DatabaseBackupExecution::class)->name('project.database.backup.danger'); }); @@ -447,8 +454,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/import-backup/{stack_service_uuid}', ServiceImportBackup::class)->name('project.service.import-backup.database')->middleware('can.update.resource'); Route::get('/storage-backups', Index::class)->name('project.service.volume-backups.index'); Route::get('/storage-backups/{backup_uuid}', Show::class)->name('project.service.volume-backups.show'); - Route::get('/storage-backups/{backup_uuid}/s3', Show::class)->name('project.service.volume-backups.s3'); - Route::get('/storage-backups/{backup_uuid}/retention', Show::class)->name('project.service.volume-backups.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/storage-backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/storage-backups/{backup_uuid}'); + Route::redirect('/storage-backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/storage-backups/{backup_uuid}'); Route::get('/storage-backups/{backup_uuid}/executions', Show::class)->name('project.service.volume-backups.executions'); Route::get('/storage-backups/{backup_uuid}/danger', Show::class)->name('project.service.volume-backups.danger'); Route::get('/scheduled-tasks', ServiceConfiguration::class)->name('project.service.scheduled-tasks.show'); @@ -460,14 +468,15 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/files', FileBrowser::class)->name('project.service.files')->middleware('can.access.terminal'); Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}', ServiceDatabaseBackups::class)->name('project.service.database.backup.show'); - Route::get('/{stack_service_uuid}/backups/{backup_uuid}/s3', ServiceDatabaseBackups::class)->name('project.service.database.backup.s3'); - Route::get('/{stack_service_uuid}/backups/{backup_uuid}/retention', ServiceDatabaseBackups::class)->name('project.service.database.backup.retention'); + Route::redirect('/{stack_service_uuid}/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups/{backup_uuid}'); + Route::redirect('/{stack_service_uuid}/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups/{backup_uuid}'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/executions', ServiceDatabaseBackups::class)->name('project.service.database.backup.executions'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/danger', ServiceDatabaseBackups::class)->name('project.service.database.backup.danger'); Route::get('/{stack_service_uuid}/import', ServiceImportBackup::class)->name('project.service.database.import')->middleware('can.update.resource'); Route::get('/{stack_service_uuid}/advanced', ServiceIndex::class)->name('project.service.index.advanced'); Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index'); - Route::get('/tasks/{task_uuid}', ServiceConfiguration::class)->name('project.service.scheduled-tasks'); + // Tasks open in a modal on the list page; keep old links (e.g. notifications) working. + Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.service.scheduled-tasks.show', get_route_parameters()))->name('project.service.scheduled-tasks'); }); Route::get('/servers', ServerIndex::class)->name('server.index'); @@ -570,15 +579,29 @@ Route::middleware(['auth'])->group(function () { } } $filename = data_get($execution, 'filename'); - if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { - $server = $execution->scheduledDatabaseBackup->database->service->destination->server; - } else { - $server = $execution->scheduledDatabaseBackup->database->destination->server; + if (! $execution->local_storage_deleted && filled($filename)) { + if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { + $server = $execution->scheduledDatabaseBackup->database->service->destination->server; + } else { + $server = $execution->scheduledDatabaseBackup->database->destination->server; + } + + try { + return streamBackupFromServer($server, $filename, 'application/octet-stream'); + } catch (FileNotFoundException) { + // Fall back to an S3 copy. + } } - return streamBackupFromServer($server, $filename, 'application/octet-stream'); - } catch (FileNotFoundException) { - if (isset($execution) && $execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) { + // Only storages of the team that owns the database can serve the copy. + $s3DownloadUrl = is_null($execution_team_id) + ? null + : $execution->s3DownloadUrl($execution_team_id, $execution->scheduledDatabaseBackup->s3_storage_id); + if ($s3DownloadUrl) { + return redirect()->away($s3DownloadUrl); + } + + if ($execution->scheduledDatabaseBackup->disable_local_backup && $execution->scheduledDatabaseBackup->save_s3) { return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404); } @@ -605,17 +628,29 @@ Route::middleware(['auth'])->group(function () { if ($team->id !== 0 && $team->id !== $execution->scheduledVolumeBackup->team_id) { return response()->json(['message' => 'Permission denied.'], 403); } - if ($execution->local_storage_deleted || blank($execution->filename)) { + if (blank($execution->filename)) { return response()->json(['message' => 'Backup not found locally on the server.'], 404); } - $server = $execution->scheduledVolumeBackup->server(); - if (! $server) { + $server = $execution->local_storage_deleted ? null : $execution->scheduledVolumeBackup->server(); + if ($server) { + try { + return streamBackupFromServer($server, $execution->filename, 'application/gzip'); + } catch (FileNotFoundException) { + // Fall back to an S3 copy. + } + } + + // Only storages of the team that owns the backup can serve the copy. + $s3DownloadUrl = $execution->s3DownloadUrl($execution->scheduledVolumeBackup->team_id, $execution->s3_storage_id); + if ($s3DownloadUrl) { + return redirect()->away($s3DownloadUrl); + } + + if (! $execution->local_storage_deleted && ! $server) { return response()->json(['message' => 'Server not found.'], 404); } - return streamBackupFromServer($server, $execution->filename, 'application/gzip'); - } catch (FileNotFoundException) { return response()->json(['message' => 'Backup not found locally on the server.'], 404); } catch (Throwable) { return response()->json(['message' => 'Failed to download backup.'], 500); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 3479e8db11..93d38c78e5 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -86,7 +86,6 @@ it('enables and disables S3 backups from the S3 title action', function () { $component = Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, - 'section' => 's3', ]) ->assertSee('Enable S3') ->call('toggleS3') @@ -117,7 +116,6 @@ it('shows and saves S3 retention while S3 backups are disabled', function () { Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, - 'section' => 'retention', ]) ->assertSee('S3 backups') ->set('databaseBackupRetentionAmountS3', 12) @@ -147,34 +145,18 @@ it('splits standalone database backup settings and executions across dedicated u $this->get($generalUrl) ->assertOk() ->assertSee('General') - ->assertSee('S3 storage') - ->assertSee('Retention') ->assertSee('Executions') ->assertSee('Danger Zone') ->assertSee('Frequency') - ->assertDontSee('Enable S3') - ->assertDontSee('Disable S3') - ->assertDontSee('Backups to keep') - ->assertDontSee('Clean failed backups') - ->assertDontSee('Delete schedule'); - - $this->get($generalUrl.'/s3') - ->assertOk() ->assertSeeText('No validated S3 storage') - ->assertDontSee('Local copy') - ->assertDontSee('Enable S3') - ->assertDontSee('Disable S3') - ->assertDontSee('Backups to keep') - ->assertDontSee('Frequency') - ->assertDontSee('Clean failed backups'); - - $this->get($generalUrl.'/retention') - ->assertOk() ->assertSee('Local backups') ->assertSee('S3 backups') ->assertSee('Backups to keep') - ->assertDontSee('Frequency') - ->assertDontSee('Clean failed backups'); + ->assertDontSee('Clean failed backups') + ->assertDontSee('Delete schedule'); + + $this->get($generalUrl.'/s3')->assertRedirect($generalUrl); + $this->get($generalUrl.'/retention')->assertRedirect($generalUrl); $this->get($generalUrl.'/executions') ->assertOk() @@ -478,22 +460,21 @@ it('shows available S3 storages even when S3 backup is disabled', function () { 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('First S3') ->assertSee('Second S3'); }); -it('shows only an empty S3 state when no storages are available', function () { +it('shows an empty S3 state when no storages are available', function () { $backup = createBackupForEditValidationTest($this->team, [ 'save_s3' => false, 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSeeText('No validated S3 storage') ->assertSeeHtml('href="'.route('storage.index').'"') ->assertSeeText('Open S3 storage') - ->assertDontSee('Save') ->assertDontSee('Enable S3') ->assertDontSee('Disable S3') ->assertDontSee('S3 Storage') @@ -510,7 +491,6 @@ it('allows S3 backups to be disabled when no usable storage remains', function ( $component = Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => collect(), - 'section' => 's3', ]) ->assertSet('saveS3', true) ->assertSeeText('No validated S3 storage') @@ -532,7 +512,7 @@ it('shows when S3 backups are currently disabled', function () { 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) // Disabled S3 backups are indicated by the "Enable S3" title action ->assertSee('S3 storage') ->assertSee('Enable S3') @@ -617,3 +597,84 @@ it('disables Back up now after refresh when the database stops', function () { expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); }); + +it('shows the backup actions in the header bar on every database backup page', function () { + config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); + $backup = createBackupForEditValidationTest($this->team); + $database = $backup->database; + $database->update(['status' => 'running:healthy']); + $generalUrl = route('project.database.backup.execution', [ + 'project_uuid' => $database->project()->uuid, + 'environment_uuid' => $database->environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + foreach (['', '/executions', '/danger'] as $suffix) { + expect($this->get($generalUrl.$suffix)->assertOk()->getContent()) + ->toMatch('/x-teleport="#resource-action-hud-slot".*?wire:click="toggleEnabled".*?Disable backup.*?wire:click="backupNow"/s'); + } +}); + +it('hides the header Back up now button from team members', function () { + config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); + $backup = createBackupForEditValidationTest($this->team); + $database = $backup->database; + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + $this->get(route('project.database.backup.executions', [ + 'project_uuid' => $database->project()->uuid, + 'environment_uuid' => $database->environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ])) + ->assertOk() + ->assertDontSee('wire:click="backupNow"', false); +}); + +it('denies Back up now for team members', function () { + Queue::fake(); + $backup = createBackupForEditValidationTest($this->team); + $backup->database->update(['status' => 'running:healthy']); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ]) + ->call('backupNow') + ->assertNoRedirect(); + + Queue::assertNotPushed(DatabaseBackupJob::class); +}); + +it('syncs the backup form when the header toggles the schedule', function () { + $backup = createBackupForEditValidationTest($this->team, ['enabled' => true]); + + $component = Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ])->assertSet('backupEnabled', true); + + $backup->update(['enabled' => false]); + + $component->dispatch('backupEnabledChanged') + ->assertSet('backupEnabled', false) + ->call('submit'); + + expect((bool) $backup->fresh()->enabled)->toBeFalse(); +}); + +it('toggles the schedule from the form and notifies the page header', function () { + $backup = createBackupForEditValidationTest($this->team, ['enabled' => true]); + + Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ]) + ->call('toggleEnabled') + ->assertSet('backupEnabled', false) + ->assertDispatched('backupEnabledChanged'); + + expect((bool) $backup->fresh()->enabled)->toBeFalse(); +}); diff --git a/tests/Feature/BackupS3DestinationsFormTest.php b/tests/Feature/BackupS3DestinationsFormTest.php index 211360408a..2e025fda21 100644 --- a/tests/Feature/BackupS3DestinationsFormTest.php +++ b/tests/Feature/BackupS3DestinationsFormTest.php @@ -107,7 +107,7 @@ beforeEach(function () { it('saves two S3 destinations for a database backup schedule', function () { $backup = createDatabaseBackupForDestinationsFormTest($this->database); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('First S3') ->assertSee('Second S3') ->assertSet('s3StorageIds', [$this->firstStorage->id]) @@ -304,12 +304,12 @@ it('keeps a selected destination that is unusable for a while when other setting $volumeBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); $this->secondStorage->update(['is_usable' => false]); - Livewire::test(BackupEdit::class, ['backup' => $databaseBackup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $databaseBackup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('Second S3 (unavailable)') ->set('timeout', 7200) ->call('submit') ->assertHasNoErrors(); - Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application, 'section' => 's3']) + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) ->assertSee('Second S3 (unavailable)') ->set('stopDuringBackup', true) ->call('save') diff --git a/tests/Feature/BackupS3DownloadFallbackTest.php b/tests/Feature/BackupS3DownloadFallbackTest.php new file mode 100644 index 0000000000..5eccab54b6 --- /dev/null +++ b/tests/Feature/BackupS3DownloadFallbackTest.php @@ -0,0 +1,183 @@ + 'file']); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ]); + $database = StandalonePostgresql::create([ + 'name' => 's3-download-pg', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + $this->primaryS3 = createS3ForDownloadFallbackTest($this->team, 'primary'); + $this->replicaS3 = createS3ForDownloadFallbackTest($this->team, 'replica'); + + $this->databaseBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'save_s3' => true, + 'disable_local_backup' => true, + 's3_storage_id' => $this->primaryS3->id, + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + ]); + $this->databaseExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->databaseBackup->id, + 'status' => 'success', + 'database_name' => 'postgres', + 'filename' => '/data/coolify/backups/databases/team/pg/pg-dump-postgres-1.dmp', + 'local_storage_deleted' => true, + ]); + + $this->volumeBackup = $volume->scheduledBackups()->create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + ]); + $this->volumeExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $this->volumeBackup->id, + 's3_storage_id' => $this->primaryS3->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/team/app/volume-app-data-1.tar.gz', + 'local_storage_deleted' => true, + ]); +}); + +function createS3ForDownloadFallbackTest(Team $team, string $name, bool $isUsable = true): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => "{$name}-{$team->id}-bucket", + 'endpoint' => 'https://1.1.1.1', + 'team_id' => $team->id, + 'is_usable' => $isUsable, + ]); +} + +function signInForDownloadFallbackTest($testCase, Team $team, string $role = 'owner'): void +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role]); + $testCase->actingAs($user); + session(['currentTeam' => $team]); +} + +/** + * @return array{0: string, 1: ScheduledDatabaseBackupExecution|ScheduledVolumeBackupExecution} + */ +function downloadFallbackTarget($testCase, string $type): array +{ + return $type === 'database' + ? ['download.backup', $testCase->databaseExecution] + : ['download.volume-backup', $testCase->volumeExecution]; +} + +function assertPresignedBackupRedirect($response, S3Storage $storage, string $filename): void +{ + $location = $response->assertRedirect()->headers->get('Location'); + $url = parse_url($location); + parse_str($url['query'], $query); + + expect("{$url['scheme']}://{$url['host']}{$url['path']}")->toBe("https://1.1.1.1/{$storage->bucket}".$filename) + ->and($query)->toHaveKeys(['X-Amz-Signature', 'X-Amz-Expires']) + ->and((int) $query['X-Amz-Expires'])->toBeLessThanOrEqual(15 * 60) + ->and($query['response-content-disposition'])->toContain('attachment; filename="'.basename($filename).'"'); +} + +it('redirects to a presigned URL of the primary S3 copy when no local copy exists', function (string $type) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->primaryS3, $execution->filename); +})->with(['database', 'volume']); + +it('uses a replica when the primary S3 copy is deleted or its storage is unusable', function (string $type, bool $primaryDeleted) { + [$route, $execution] = downloadFallbackTarget($this, $type); + if (! $primaryDeleted) { + $this->primaryS3->update(['is_usable' => false]); + } + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true, 's3_storage_deleted' => $primaryDeleted]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->replicaS3, $execution->filename); +})->with(['database', 'volume'])->with(['primary deleted' => true, 'primary unusable' => false]); + +it('keeps the not found message when no usable S3 copy exists', function (string $type, string $message) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true, 's3_storage_deleted' => true]); + signInForDownloadFallbackTest($this, $this->team); + + $this->get(route($route, $execution->id))->assertNotFound()->assertJson(['message' => $message]); +})->with([ + 'database' => ['database', 'Backup not available locally, but available on S3.'], + 'volume' => ['volume', 'Backup not found locally on the server.'], +]); + +it('never uses an S3 storage of another team', function (string $type) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $foreignS3 = createS3ForDownloadFallbackTest($this->otherTeam, 'foreign'); + $execution->s3Replicas()->create(['s3_storage_id' => $foreignS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + $this->get(route($route, $execution->id))->assertNotFound(); +})->with(['database', 'volume']); + +it('forbids members and other teams from getting a presigned URL', function (string $type, string $user) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]); + $user === 'member' + ? signInForDownloadFallbackTest($this, $this->team, 'member') + : signInForDownloadFallbackTest($this, $this->otherTeam); + + $this->get(route($route, $execution->id))->assertForbidden(); +})->with(['database', 'volume'])->with(['member', 'other team owner']); diff --git a/tests/Feature/BackupS3DownloadTest.php b/tests/Feature/BackupS3DownloadTest.php new file mode 100644 index 0000000000..a04d90950e --- /dev/null +++ b/tests/Feature/BackupS3DownloadTest.php @@ -0,0 +1,287 @@ + 'file', 'constants.ssh.mux_enabled' => false]); + Storage::fake('ssh-keys'); + Server::flushIdentityMap(); + Once::flush(); + InstanceSettings::forceCreate(['id' => 0]); + Team::factory()->create(['id' => 0]); + $this->downloadStorage = S3Storage::create([ + 'name' => 'Downloads', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'downloads', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => 0, + ]); + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => $this->downloadStorage->id]); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); + $this->database = StandalonePostgresql::create([ + 'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', + 'image' => 'postgres:17', 'environment_id' => $environment->id, + 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), + ]); + $this->backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + ]); + $this->execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->backup->id, + 'status' => 'success', + 'filename' => BACKUP_S3_DOWNLOAD_FILE, + ]); + $this->service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); + + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'admin']); + $this->user->teams()->attach($this->otherTeam, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + Bus::fake([PrepareFileDownloadJob::class]); +}); + +function fakeBackupFileSize(int|string $size): void +{ + Process::fake(['*' => Process::result(output: (string) $size)]); +} + +it('uploads a database backup through S3 for an admin of its team after a team switch', function () { + fakeBackupFileSize(1024); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]); + session(['currentTeam' => $this->otherTeam]); + + $component->call('download_file', $this->execution->id) + ->assertDispatched('info') + ->assertNotDispatched('open-backup-download') + ->assertCount('pendingDownloads', 1); + + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === BACKUP_S3_DOWNLOAD_FILE + && $job->serverId === $this->server->id + && $job->downloadName === 'pg-dump-orders-1.dmp' + && $job->s3StorageId === $this->downloadStorage->id + && $job->userId === $this->user->id); +}); + +it('streams through the download route when no download storage is set', function () { + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => null]); + Once::flush(); + Process::fake(); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('streams through the download route when the backup is larger than one S3 upload', function () { + fakeBackupFileSize(FileDownloadStorageService::MAX_SIZE + 1); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('lets the download route answer when the backup file is not on the server', function () { + fakeBackupFileSize(''); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('refuses team members', function () { + Process::fake(); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]); + + $component->call('download_file', $this->execution->id) + ->assertNotDispatched('open-backup-download'); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('does not download an execution of another backup', function () { + Process::fake(); + $foreignBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->otherTeam->id, + 'frequency' => 'daily', + 'database_id' => $this->database->id + 1000, + 'database_type' => $this->database->getMorphClass(), + ]); + $foreignExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $foreignBackup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/databases/other/secret.dmp', + ]); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $foreignExecution->id) + ->assertDispatched('error', 'Backup execution not found.') + ->assertNotDispatched('open-backup-download'); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('uploads a volume backup through S3 and refuses members', function () { + fakeBackupFileSize(2048); + $application = Application::factory()->create([ + 'environment_id' => $this->database->environment_id, + 'destination_id' => $this->database->destination_id, + 'destination_type' => $this->database->destination_type, + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $backup = $volume->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/app/archive.tar.gz', + ]); + $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]); + session(['currentTeam' => $this->otherTeam]); + + $component->call('downloadBackup', $execution->id)->assertCount('pendingDownloads', 1); + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === '/data/coolify/backups/volumes/app/archive.tar.gz' + && $job->serverId === $this->server->id); + + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $component->call('downloadBackup', $execution->id)->assertForbidden(); + Bus::assertDispatchedTimes(PrepareFileDownloadJob::class, 1); +}); + +it('uploads a service database backup through S3', function () { + fakeBackupFileSize(1024); + $serviceDatabase = ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'service-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'database_id' => $serviceDatabase->id, + 'database_type' => $serviceDatabase->getMorphClass(), + ]); + $execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/service/db.dmp', + ]); + + Livewire::test(ServiceBackupExecutions::class, ['service' => $this->service]) + ->call('downloadExecution', $execution->uuid) + ->assertCount('pendingDownloads', 1); + + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === '/data/coolify/backups/service/db.dmp'); +}); + +it('opens a finished link only for the user who asked for it', function () { + fakeBackupFileSize(1024); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id); + $downloadId = $component->get('pendingDownloads')[0]; + + Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id + 1, 'status' => 'ready', 'url' => 'https://s3.example.com/other']); + $component->call('checkDownloads')->assertNotDispatched('open-backup-download')->assertCount('pendingDownloads', 1); + + Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get']); + $component->call('checkDownloads') + ->assertDispatched('open-backup-download', url: 'https://s3.example.com/get') + ->assertCount('pendingDownloads', 0); +}); + +it('uploads the backup file from the server host with curl', function () { + Bus::fake([DeleteFileDownloadJob::class]); + Process::fake(['*' => Process::result(output: '')]); + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('uploadUrl')->andReturn(['url' => 'https://s3.example.com/put?X-Amz-Signature=abc&x=1', 'headers' => ['Content-Disposition' => 'attachment']]); + $downloads->shouldReceive('downloadUrl')->andReturn('https://s3.example.com/get'); + + (new PrepareFileDownloadJob('dl-host', $this->user->id, $this->server->id, null, BACKUP_S3_DOWNLOAD_FILE, false, 'pg.dmp', $this->downloadStorage->id))->handle($downloads); + + expect(Cache::get(PrepareFileDownloadJob::cacheKey('dl-host')))->toMatchArray(['status' => 'ready', 'url' => 'https://s3.example.com/get']); + Process::assertRan(fn ($process) => str_contains($process->command, 'curl -fsS') + && str_contains($process->command, BACKUP_S3_DOWNLOAD_FILE) + && str_contains($process->command, 'X-Amz-Signature=abc&x=1') + && ! str_contains($process->command, 'docker exec')); +}); + +it('builds host file commands that the non-root sudo parser keeps whole', function () { + $nonRoot = Server::factory()->make(['user' => 'deploy']); + $upload = FileDownloadStorageService::buildHostFileUploadCommand("/data/it's.dmp", 'https://s3.example.com/put?a=1&b=2', ['Content-Disposition' => 'attachment']); + $size = FileDownloadStorageService::buildHostFileSizeCommand("/data/it's.dmp"); + + expect(parseCommandsByLineForSudo(collect([$upload, $size]), $nonRoot))->toBe(['sudo '.$upload, 'sudo '.$size]) + ->and(fn () => FileDownloadStorageService::buildHostFileSizeCommand('/data/a;rm -rf /'))->toThrow(Exception::class); +}); diff --git a/tests/Feature/BackupS3StorageListResourceTeamTest.php b/tests/Feature/BackupS3StorageListResourceTeamTest.php index 90f87493b4..d4b61b16b4 100644 --- a/tests/Feature/BackupS3StorageListResourceTeamTest.php +++ b/tests/Feature/BackupS3StorageListResourceTeamTest.php @@ -112,7 +112,7 @@ test('database backup page offers only usable S3 storages of the database team', 'database_id' => $this->postgres->id, 'database_type' => $this->postgres->getMorphClass(), ]); - $url = route('project.database.backup.s3', [ + $url = route('project.database.backup.execution', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'database_uuid' => $this->postgres->uuid, diff --git a/tests/Feature/DashboardCanvasTest.php b/tests/Feature/DashboardCanvasTest.php new file mode 100644 index 0000000000..9204b3e77a --- /dev/null +++ b/tests/Feature/DashboardCanvasTest.php @@ -0,0 +1,132 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true])); + Server::flushIdentityMap(); + + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin->id, ['role' => 'admin']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'web-server']); + $this->environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ]); +}); + +function dashboardCanvasApp(Environment $environment, Server $server, string $name): Application +{ + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + + return Application::factory()->create([ + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +function dashboardCanvasDeployment(Application $application, Server $server, ApplicationDeploymentStatus $status): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::query()->forceCreate([ + 'application_id' => (string) $application->id, + 'application_name' => $application->name, + 'deployment_uuid' => (string) str()->uuid(), + 'deployment_url' => '/deployment/'.$status->value, + 'server_id' => $server->id, + 'server_name' => $server->name, + 'status' => $status->value, + ]); +} + +function dashboardCanvasSession(object $test, User $user, Team $team): object +{ + return $test->actingAs($user)->withSession(['currentTeam' => $team]) + ->withHeader('X-Canvas-Team', (string) $team->id); +} + +test('the dashboard canvas marks applications with an active deployment and lists recent deployments', function () { + $deploying = dashboardCanvasApp($this->environment, $this->server, 'storefront'); + $queued = dashboardCanvasApp($this->environment, $this->server, 'orders-api'); + $idle = dashboardCanvasApp($this->environment, $this->server, 'docs'); + dashboardCanvasDeployment($deploying, $this->server, ApplicationDeploymentStatus::IN_PROGRESS); + dashboardCanvasDeployment($queued, $this->server, ApplicationDeploymentStatus::QUEUED); + dashboardCanvasDeployment($idle, $this->server, ApplicationDeploymentStatus::FINISHED); + + $response = dashboardCanvasSession($this, $this->admin, $this->team) + ->getJson('/canvas/api/dashboard/canvas') + ->assertOk() + ->assertJsonPath('title', 'Dashboard') + ->assertJsonPath('zones.0.id', $this->server->uuid); + $nodes = collect($response->json('nodes'))->keyBy('id'); + + expect($nodes[$deploying->uuid]['deployment'])->toBe(['status' => 'in_progress', 'link' => '/deployment/in_progress', 'pull_request_id' => null]) + ->and($nodes[$queued->uuid]['deployment']['status'])->toBe('queued') + ->and($nodes[$idle->uuid])->not->toHaveKey('deployment') + // The deployment panel lists running and queued deployments first, then the finished ones. + ->and(collect($response->json('deployments'))->map(fn (array $row) => [$row['application'], $row['status']])->all())->toBe([ + [$deploying->uuid, 'in_progress'], + [$queued->uuid, 'queued'], + [$idle->uuid, 'finished'], + ]); +}); + +test('the dashboard canvas never shows another team servers, resources, or deployments', function () { + $otherTeam = Team::factory()->create(['show_boarding' => false]); + $outsider = User::factory()->create(); + $otherTeam->members()->attach($outsider->id, ['role' => 'owner']); + $this->team->members()->attach($outsider->id, ['role' => 'member']); + $app = dashboardCanvasApp($this->environment, $this->server, 'storefront'); + dashboardCanvasDeployment($app, $this->server, ApplicationDeploymentStatus::IN_PROGRESS); + + $response = dashboardCanvasSession($this, $outsider, $otherTeam) + ->getJson('/canvas/api/dashboard/canvas') + ->assertOk(); + + expect($response->json('zones'))->toBe([]) + ->and($response->json('nodes'))->toBe([]) + ->and($response->json('deployments'))->toBe([]); +}); + +test('the dashboard opens the canvas again when the user left it there', function () { + $cookie = "coolify_{$this->admin->id}_dashboard_view"; + + dashboardCanvasSession($this, $this->admin, $this->team) + ->withoutVite() + ->get(route('dashboard.canvas')) + ->assertOk() + ->assertSee('data-endpoint="/dashboard/canvas"', false) + ->assertCookie($cookie, 'canvas'); + + dashboardCanvasSession($this, $this->admin, $this->team) + ->withCookie($cookie, 'canvas') + ->get(route('dashboard')) + ->assertRedirect(route('dashboard.canvas')); + + // The list link on the canvas opens the list and makes it the remembered view. + dashboardCanvasSession($this, $this->admin, $this->team) + ->withCookie($cookie, 'canvas') + ->get(route('dashboard', ['view' => 'list'])) + ->assertOk() + ->assertCookie($cookie, 'list'); +}); diff --git a/tests/Feature/DashboardDeploymentsTest.php b/tests/Feature/DashboardDeploymentsTest.php index 6ef6f05ab0..1fa548da4f 100644 --- a/tests/Feature/DashboardDeploymentsTest.php +++ b/tests/Feature/DashboardDeploymentsTest.php @@ -55,7 +55,6 @@ function createDashboardDeployment(array $overrides = []): ApplicationDeployment it('hides the deployments section when there is nothing to show', function () { Livewire::test(ActiveDeployments::class) - ->assertDontSee('Active and recent deployment activity') ->assertDontSee('Running or queued right now') ->assertDontSee('Latest completed deployments') ->assertDontSee('No active deployments') diff --git a/tests/Feature/DashboardViewModeTest.php b/tests/Feature/DashboardViewModeTest.php new file mode 100644 index 0000000000..a18145b0f9 --- /dev/null +++ b/tests/Feature/DashboardViewModeTest.php @@ -0,0 +1,44 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('shows the projects and servers sections as tables by default', function () { + Livewire::test(Dashboard::class) + ->assertSee("viewMode: 'table'", false) + ->assertDontSee("viewMode: 'grid'", false); +}); + +it('remembers the view of each dashboard section on its own', function () { + Livewire::test(Dashboard::class)->call('saveListView', 'dashboard_projects', 'grid'); + + expect(Cookie::queued("coolify_{$this->user->id}_dashboard_projects_view")?->getValue())->toBe('grid') + ->and(Cookie::queued("coolify_{$this->user->id}_dashboard_servers_view"))->toBeNull(); + + $this->withCookie("coolify_{$this->user->id}_dashboard_projects_view", 'grid') + ->get(route('dashboard')) + ->assertOk() + ->assertSee("viewMode: 'grid'", false) + ->assertSee("viewMode: 'table'", false); +}); diff --git a/tests/Feature/EnvironmentCanvasTest.php b/tests/Feature/EnvironmentCanvasTest.php index 8d28c0cea8..ab4e403489 100644 --- a/tests/Feature/EnvironmentCanvasTest.php +++ b/tests/Feature/EnvironmentCanvasTest.php @@ -133,9 +133,9 @@ describe('GET canvas', function () { 'kind' => 'nixpacks', 'subtitle' => 'Nixpacks · main', 'children' => [], - 'volumes' => ['web-data'], 'link' => $application->link(), 'logo' => ['src' => resource_logo_url('public-repo'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => 'https://web.example.com/favicon.ico'], + 'saved' => ['x' => 10, 'y' => -20], ]) ->and($nodes[$service->uuid])->toEqual([ 'id' => $service->uuid, @@ -151,9 +151,9 @@ describe('GET canvas', function () { ['name' => 'ghost', 'type' => 'application', 'status' => 'running:healthy'], ['name' => 'mysql', 'type' => 'database', 'status' => 'running:healthy'], ], - 'volumes' => [], 'link' => $service->link(), 'logo' => null, + 'saved' => null, ]) ->and($nodes[$database->uuid])->toMatchArray([ 'type' => 'database', @@ -162,8 +162,8 @@ describe('GET canvas', function () { 'urls' => [], 'kind' => 'postgresql', 'subtitle' => 'PostgreSQL · postgres:17-alpine', - 'volumes' => ["postgres-data-{$database->uuid}"], 'logo' => ['src' => resource_logo_url('postgres'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => null], + 'saved' => null, ]) ->and($response->getContent())->not->toContain('canvas-secret-password'); }); diff --git a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php index 2cbac04a38..551e0cef0f 100644 --- a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php +++ b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php @@ -93,7 +93,7 @@ it('saves several S3 destinations for the instance backup and drops storages of // The session team can change in another tab after the page loads. session(['currentTeam' => ['id' => $this->otherTeam->id]]); - Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect(), 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect()]) ->set('saveS3', true) ->set('s3StorageIds', [$this->firstStorage->id, $this->secondStorage->id, $this->foreignStorage->id]) ->call('submit') diff --git a/tests/Feature/NavbarSubmenuTest.php b/tests/Feature/NavbarSubmenuTest.php new file mode 100644 index 0000000000..b336b46a80 --- /dev/null +++ b/tests/Feature/NavbarSubmenuTest.php @@ -0,0 +1,133 @@ + 0]); + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); +}); + +function renderNavbarAt(string $path, bool $mobile = false): string +{ + app()->instance('request', Request::create($path)); + + return (string) test()->blade($mobile ? '' : ''); +} + +function loginNavbarUser(string $role): void +{ + test()->team->members()->attach(test()->user->id, ['role' => $role]); + test()->actingAs(test()->user); + session(['currentTeam' => test()->team]); +} + +test('a submenu page opens its submenu with the current page active', function () { + loginNavbarUser('owner'); + + $html = renderNavbarAt('/metrics'); + + expect($html) + ->toContain('Back to the main menu') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Metrics#s'); +}); + +test('a page outside every submenu starts on the main menu', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/servers'))->not->toContain('data-active-submenu="'); +}); + +test('a submenu entry links to its first visible page', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/servers'))->toContain('href="'.route('analytics').'"'); +}); + +test('the registries page is hidden from members in the integrations submenu', function () { + loginNavbarUser('member'); + + expect(renderNavbarAt('/sources')) + ->toContain('Destinations') + ->not->toContain('href="'.route('registries.index').'"'); +}); + +test('the team submenu lists the team pages by role', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/team/members')) + ->toContain('href="'.route('team.member.index').'"') + ->toContain('href="'.route('team.audit-log').'"') + ->toContain('href="'.route('team.danger-zone').'"'); +}); + +test('members do not see the audit log or danger zone in the team submenu', function () { + loginNavbarUser('member'); + + expect(renderNavbarAt('/team')) + ->toContain('href="'.route('team.member.index').'"') + ->not->toContain('href="'.route('team.audit-log').'"') + ->not->toContain('href="'.route('team.danger-zone').'"'); +}); + +test('notifications and keys & tokens open their own submenus', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/notifications/discord')) + ->toContain('href="'.route('notifications.slack').'"') + ->toMatch('#aria-current="page"[^>]*>.*?discord\.svg#s'); + + expect(renderNavbarAt('/security/api-tokens')) + ->toContain('href="'.route('security.private-key.index').'"') + ->toContain('Back to the main menu'); +}); + +test('the admin link belongs to the main menu, not to submenus', function () { + config()->set('app.env', 'local'); + $root = User::factory()->create(['id' => 0]); + test()->team->members()->attach($root->id, ['role' => 'owner']); + test()->actingAs($root); + session(['currentTeam' => test()->team]); + + expect(renderNavbarAt('/team')) + ->toMatch('#]*>\s*toContain('Back to the main menu') + ->toContain('href="'.route('shared-variables.team.index').'"') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Servers#s'); +}); + +test('instance settings open their own submenu for instance admins', function () { + config()->set('app.env', 'local'); + $root = User::factory()->create(['id' => 0]); + test()->team->members()->attach($root->id, ['role' => 'owner']); + test()->actingAs($root); + session(['currentTeam' => test()->team]); + + expect(renderNavbarAt('/settings/email')) + ->toContain('Back to the main menu') + ->toContain('href="'.route('settings.oauth').'"') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Email#s'); +}); + +test('a parent entry opens its submenu in place on mobile and desktop', function () { + loginNavbarUser('owner'); + + foreach ([renderNavbarAt('/servers', mobile: true), renderNavbarAt('/servers')] as $html) { + expect($html) + ->toContain('x-show="submenu === \'Monitoring\'"') + ->toMatch('#