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
This commit is contained in:
Andras Bacsai committed 2026-10-14 23:55:28 +02:00
1 parent ad526a4c32
commit fa1895c99a
134 files changed
+4255 -1594

No files matched your search

+7 -2
View File
@@ -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
@@ -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);
+11 -5
View File
@@ -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.']);
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Livewire\Concerns;
use App\Jobs\DatabaseBackupJob;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Http\RedirectResponse;
use Livewire\Features\SupportRedirects\Redirector;
trait ManagesDatabaseBackup
{
/**
* Turn the backup schedule on or off and return the new state.
*/
protected function toggleDatabaseBackup(ScheduledDatabaseBackup $backup): bool
{
$this->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,
]);
}
}
@@ -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
+9
View File
@@ -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()
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Livewire\Dashboard;
use App\Livewire\Concerns\RemembersCanvasView;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Dashboard as a canvas: every server of the team with its resources and their running deployments.
* The page is a shell: resources/js/environment-canvas.js renders it from
* Api\ServerCanvasController::dashboard at /canvas/api with the browser session.
*/
class Canvas extends Component
{
use RemembersCanvasView;
/** The team that owned the page at mount; the canvas API calls use it, not the session team. */
#[Locked]
public int $teamId;
public function mount(): void
{
$this->teamId = currentTeam()->id;
$this->rememberView('dashboard', 'canvas');
}
public function render(): View
{
return view('livewire.dashboard.canvas')->layout('layouts.app', ['fullBleed' => true]);
}
}
+2 -2
View File
@@ -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()
@@ -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',
@@ -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');
+28 -53
View File
@@ -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();
@@ -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(),
]);
}
}
@@ -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,
];
@@ -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',
@@ -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',
@@ -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(),
]);
}
}
@@ -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);
}
@@ -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.'],
+7 -2
View File
@@ -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()),
]);
}
}
+1 -3
View File
@@ -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" && '
+6 -12
View File
@@ -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(),
@@ -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<string, string> $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<string, string> $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';
+10 -13
View File
@@ -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<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, positions: array<string, array{x: int, y: int}>}
* @return array{title: string, title_link: string, can_edit: bool, can_move: false, layout: 'server', zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, 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<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, positions: array<string, array{x: int, y: int}>}
* @return array{title: string, title_link: string, can_edit: bool, can_move: false, layout: 'server', zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, 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}}
*/
+126 -18
View File
@@ -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<int, Server> $servers servers of the team to show
* @return array{title: string, title_link: ?string, can_edit: bool, layout: 'server', zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, positions: array<string, array{x: int, y: int}>}
* @return array{title: string, title_link: ?string, can_edit: bool, can_move: false, can_move_groups: bool, layout: 'server', zones: array<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, 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<int, Server> $servers servers of the team to show
* @return array<string, mixed>
*/
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');
+75
View File
@@ -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<string, array{variables: int, owners: int}>, recent: Collection<int, array<string, mixed>>, used: Collection<int, array<string, mixed>>, unused: Collection<int, array<string, mixed>>, 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.
*/
+42
View File
@@ -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 "<bucket><backup directory>/", 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.
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Traits;
use App\Jobs\PrepareFileDownloadJob;
use App\Models\Server;
use App\Services\FileDownloadStorageService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
/**
* Backup downloads through the instance download S3 storage: the server
* uploads the backup to S3 and the browser downloads it from there. Without
* the storage, when the local file is gone, or when the backup is larger
* than one S3 PUT, the browser uses the Coolify download route.
*
* The view must poll checkDownloads() while $pendingDownloads is not empty
* and open the URL of each `open-backup-download` window event.
*/
trait PreparesBackupDownloads
{
private const MAX_PENDING_S3_DOWNLOADS = 3;
/** @var array<int, string> */
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;
}
}
+1 -1
View File
@@ -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),
+4 -1
View File
@@ -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"]
+2 -2
View File
@@ -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"
+107 -29
View File
@@ -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 <select>) */
.listbox-trigger {
display: flex;
@@ -2700,13 +2704,6 @@ input[type="search"]::-webkit-search-results-decoration {
box-shadow: inset 0 0 0 1px var(--coollabs-line);
}
/* Reserve the full settings sidebar width on desktop. */
@media (min-width: 1280px) {
.files-grid {
grid-template-columns: calc(210px + 2.5rem) minmax(0, 1fr);
}
}
.environment-table-scroll {
overflow-x: auto;
overscroll-behavior-x: contain;
@@ -3219,8 +3216,9 @@ input[type="search"]::-webkit-search-results-decoration {
}
}
/* Status and Started only fit a badge and a short "3 hours ago"; the names get the rest. */
.dashboard-deployment-table-grid {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(6.25rem, 0.75fr) 10rem 8rem;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 0.7fr) 6.5rem 6rem;
}
.team-members-table-grid {
@@ -3290,6 +3288,11 @@ input[type="search"]::-webkit-search-results-decoration {
overflow-x: auto;
}
/* Match the grid width so the log output spans the full scrolled row. */
.backup-execution-row {
min-width: 66rem;
}
.backup-execution-row:last-child > .data-table-row {
border-bottom: 0;
}
@@ -3426,9 +3429,13 @@ input[type="search"]::-webkit-search-results-decoration {
display: none;
}
.backup-executions-table-grid,
.backup-execution-row {
min-width: 42rem;
}
.backup-executions-table-grid {
grid-template-columns: 7.5rem 9rem minmax(14rem, 1fr) minmax(7rem, auto);
min-width: 42rem;
}
.backup-executions-table-grid > :nth-child(4),
@@ -5275,18 +5282,24 @@ a.command-palette-item:focus-visible {
has no background. Applies to both navigation styles. Appearance →
Navigation items → Flat sets data-nav-items="flat" on <html> and turns these
rules off, which brings back the plain rows. */
html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) {
html:where(:not([data-nav-items="flat"])) .menu-item:not(.sidebar-toggle) {
height: 2.5rem;
padding-left: 0.25rem;
}
/* In-page section rows have no icon tile, so they stay compact. */
html:where(:not([data-nav-items="flat"])) .menu-subitem {
height: 1.75rem;
}
/* Only the icon tile reacts: no row background on hover or for the current
item; the current item shows through its tile and its brighter label.
In the utilities layer so it can beat the search row's !bg-* utilities
(a layered !important wins over an unlayered one). */
@layer utilities {
html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle),
html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover {
/* The submenu Back row (navbar) keeps its fill. */
html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle, .menu-back),
html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle, .menu-back):hover {
background-color: transparent !important;
}
@@ -5331,6 +5344,13 @@ html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subi
--button-depth-color: color-mix(in oklab, var(--color-warning) 35%, transparent) !important;
}
/* Danger Zone icons (<x-reicon name="danger">) stay red, also when active. */
html:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon.text-error {
color: var(--color-error);
border-color: color-mix(in oklab, var(--color-error) 45%, transparent) !important;
--button-depth-color: color-mix(in oklab, var(--color-error) 40%, transparent) !important;
}
html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):focus-visible {
outline: none;
}
@@ -5340,6 +5360,47 @@ html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.si
outline-offset: 2px;
}
/* Sidebar main menu/submenu swap: the submenu enters from the right, the main menu from the left.
CSS animations restart when an element is shown again, so x-show replays them on each swap. */
@keyframes nav-swap-forward {
from {
opacity: 0;
transform: translateX(0.5rem);
}
}
@keyframes nav-swap-back {
from {
opacity: 0;
transform: translateX(-0.5rem);
}
}
.nav-swap-forward {
animation: nav-swap-forward 150ms ease-out;
}
.nav-swap-back {
animation: nav-swap-back 150ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.nav-swap-forward,
.nav-swap-back {
animation: none;
}
}
/* Submenu Back chevron box: one icon wide (flat), or one icon tile wide (16px + padding + border). */
.menu-back-icon {
width: 1rem;
height: 1rem;
}
html:where(:not([data-nav-items="flat"])) .menu-back-icon {
width: calc(1rem + 0.875rem + 2px);
}
html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon {
box-sizing: content-box;
width: 1rem;
@@ -5526,7 +5587,7 @@ html.dark .canvas-zoom-max {
background-color: var(--color-panel);
}
html[data-nav-style="floating"] :is(.app-topbar, .app-sidebar > div > nav, .app-tabbar) {
html[data-nav-style="floating"] :is(.app-topbar, .app-sidebar > div > nav) {
border: 1px solid var(--floating-line);
border-radius: var(--floating-radius);
}
@@ -5617,10 +5678,11 @@ html.dark .canvas-zoom-max {
padding-bottom: var(--floating-gap);
}
html[data-nav-style="floating"] .app-tabbar {
top: var(--floating-top);
/* Dashboard canvas Deployments panel: one gap from the floating sidebar and the screen bottom. */
/* Bottom-left zoom controls and deployments: the canvas fills the screen behind the sidebar, so start one gap after it. */
html[data-nav-style="floating"] .app-main-full [data-ec-dock] {
left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2);
right: var(--floating-gap);
bottom: var(--floating-gap);
}
}
@@ -5658,7 +5720,7 @@ html.dark .canvas-zoom-max {
}
/* Full page width: the content starts one gap after the settings rail. */
html[data-nav-style="floating"] .app-main-wide :is(:has(> .application-settings-navigation), .files-grid) {
html[data-nav-style="floating"] .app-main-wide :has(> .application-settings-navigation) {
grid-template-columns: calc(210px + 2.5rem) minmax(0, 1fr);
column-gap: var(--floating-gap);
}
@@ -5713,3 +5775,19 @@ html.dark .canvas-zoom-max {
html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.1]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; }
html.dark[data-nav-style="floating"] .app-main .button:not(:disabled):not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; }
}
/* Resource menu in the collapsed main sidebar: icons only. */
.sidebar-collapsed .nav-context-menu :is(.menu-item-label, .nav-section-toggle, .nav-children, .menu-item > :not(.menu-item-icon)) {
display: none !important;
}
.sidebar-collapsed .nav-context-menu .menu-item {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
/* A page menu's own "Back to ..." item: the main sidebar copy has the back row instead (components/main-nav-menu). */
.nav-context-menu [data-page-only] {
display: none !important;
}
+10
View File
@@ -435,6 +435,16 @@
padding-right: 0.625rem;
}
/* Flat rows show their own fill, so make it a 2rem square (same as the h-8
height) with the icon centered, in line with the footer controls. */
html[data-nav-items="flat"] .sidebar-collapsed .menu-item:not(.sidebar-toggle) {
width: 2rem;
margin-left: 0.1875rem;
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.sidebar-collapsed .sidebar-collapsed-label {
display: none;
}
+20
View File
@@ -0,0 +1,20 @@
<script>
// Zoom and fit controls that float in the bottom-left corner of a canvas.
// With `onrefresh` (environment canvas) it also has a Refresh button. `inline`: the parent places it.
import Icon from './environment/Icon.svelte';
let { zoom, onzoom, onfit, onrefresh = null, loading = false, inline = false } = $props();
const button = 'grid h-7 min-w-7 place-items-center rounded-md px-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:pointer-events-none disabled:opacity-50 dark:text-fg-dim dark:hover:bg-white/[0.07] dark:hover:text-fg';
</script>
<div class="{inline ? 'shrink-0' : 'absolute bottom-3 left-3 z-20'} flex items-center gap-0.5 rounded-lg border border-neutral-200 bg-white/85 p-0.5 shadow-sm backdrop-blur-sm dark:border-white/[0.08] dark:bg-panel/85" data-canvas-controls>
<button type="button" class={button} title="Zoom out" aria-label="Zoom out" onclick={() => onzoom(zoom / 1.2)}><Icon name="minus" /></button>
<button type="button" class="{button} w-12 text-[12px] font-medium tabular-nums" title="Reset zoom to 100%" onclick={() => onzoom(1)}>{Math.round(zoom * 100)}%</button>
<button type="button" class={button} title="Zoom in" aria-label="Zoom in" onclick={() => onzoom(zoom * 1.2)}><Icon name="plus" /></button>
<span class="mx-0.5 h-4 w-px bg-neutral-200 dark:bg-white/[0.08]"></span>
<button type="button" class={button} title="Fit to screen" aria-label="Fit to screen" onclick={onfit}><Icon name="fit" /></button>
{#if onrefresh}
<button type="button" class={button} title="Load the latest status" aria-label="Refresh" disabled={loading} onclick={onrefresh}><Icon name="refresh" class="size-3.5 {loading ? 'animate-spin' : ''}" /></button>
{/if}
</div>
+3 -11
View File
@@ -7,11 +7,11 @@
import Node from './Node.svelte';
import PaletteItem from './PaletteItem.svelte';
import RunPanel from './RunPanel.svelte';
import CanvasControls from '../CanvasControls.svelte';
import { closeMenu, cv, dock, els, fit, hideResult, leave, pan, pickNext, PORT_Y, resetCanvas } from './state.svelte.js';
// The deploy canvas: header, step palette, and the canvas with the step
// cards. The state lives in state.svelte.js (init() runs before the mount).
let zoomLabel = $state();
// Theme: same as Coolify (html.dark).
let dark = $state(document.documentElement.classList.contains('dark'));
// Palette: full or icons only ('coolify-canvas:collapsed').
@@ -77,18 +77,9 @@
<!-- Opened from an environment's New resource page: back to that environment. -->
<a class="cv-back button px-2!" href="/project/{cv.target.project}/environment/{cv.target.environment}" title="Back to the environment" aria-label="Back to the environment"><Icon name="back" cls="size-4" /></a>
{/if}
<b class="shrink-0 text-[14px] font-semibold text-black dark:text-fg">Deploy canvas</b>
<span class="shrink-0 rounded-full border border-coollabs/30 bg-coollabs/10 px-2 py-0.5 text-xs font-medium text-coollabs dark:border-warning/30 dark:bg-warning/15 dark:text-warning">Beta</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<!-- Zoom controls, as on the environment canvas header. -->
<div class="flex items-center">
<button type="button" class="button px-2! rounded-r-none!" title="Zoom out" aria-label="Zoom out" onclick={() => els.pz.zoomTo(cv.view.z / 1.2)}><Icon name={ICONS.minus} cls="size-4" /></button>
<!-- Its text is the zoom level, written by panZoom. -->
<button bind:this={zoomLabel} type="button" class="button rounded-none! border-x-0! w-14 tabular-nums" title="Reset zoom to 100%" onclick={() => els.pz.zoomTo(1)}>100%</button>
<button type="button" class="button px-2! rounded-l-none!" title="Zoom in" aria-label="Zoom in" onclick={() => els.pz.zoomTo(cv.view.z * 1.2)}><Icon name={ICONS.plus} cls="size-4" /></button>
</div>
<button type="button" class="button px-2!" title="Fit to screen" aria-label="Fit to screen" onclick={fit}><Icon name={ICONS.fit} cls="size-4" />Fit</button>
<button type="button" class="button" title="Start again with an empty canvas" onclick={askReset}>Reset</button>
</div>
</div>
@@ -115,7 +106,7 @@
</aside>
<section class="cv-island cv-stage">
<div class="cv-viewport" use:pan={() => zoomLabel}>
<div class="cv-viewport" use:pan>
<div class="cv-world">
<Edges />
{#each cv.flow.nodes as n (n.id)}
@@ -143,6 +134,7 @@
{/if}
</div>
</div>
<CanvasControls zoom={cv.view.z} onzoom={z => els.pz.zoomTo(z)} onfit={fit} />
<RunPanel />
{#if cv.menu}
<div class="cv-menu" style:left="{cv.menu.left}px" style:top="{cv.menu.top}px">
+3 -4
View File
@@ -68,8 +68,8 @@ export function leave() {
// ── Actions ────────────────────────────────────────────────────────────
/** use:pan on the viewport: wheel and pinch zoom, drag to pan. zoomLabel() is the zoom button. */
export function pan(viewport, zoomLabel) {
/** use:pan on the viewport: wheel and pinch zoom, drag to pan. */
export function pan(viewport) {
const world = viewport.querySelector('.cv-world');
const edges = viewport.querySelector('.cv-edges');
els.viewport = viewport;
@@ -79,8 +79,7 @@ export function pan(viewport, zoomLabel) {
root: els.root,
viewport,
world,
// The button can sit in the app top bar (see dock), so it is looked up on each write.
zoomLabel: { set textContent(text) { const label = zoomLabel(); if (label) label.textContent = text; } },
zoomLabel: {}, // CanvasControls shows cv.view.z
getView: () => cv.view,
onChange: closeDropdown,
canPan: e => [viewport, world, edges].includes(e.target),
+1 -1
View File
@@ -30,7 +30,7 @@ const CSS = `
/* Rounded boxes on the backdrop: the top bar and the step list. */
.cv-island { border: 1px solid var(--cv-line); border-radius: 8px; background: var(--cv-island); }
.cv-root svg { width: 1rem; height: 1rem; flex: none; }
.cv-root button:not(.button) { font: inherit; }
.cv-root button:not(.button, [data-canvas-controls] button) { font: inherit; }
/* Header island on small screens; on desktop the header docks into the app top bar. */
.cv-top { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; padding: 0.625rem 0.75rem; }
+37 -10
View File
@@ -1,20 +1,29 @@
<script>
// A resource card (or, connect view, a shared variable / tag card).
import { isSource, MAX_CHILDREN, MAX_VOLUMES, statusLabel, TYPE_LABELS } from '../../environment-canvas.js';
import { isSource, MAX_CHILDREN, statusLabel, statusTone, statusWord, TYPE_LABELS } from '../../environment-canvas.js';
import NodeIcon from './NodeIcon.svelte';
import StatusDot from './StatusDot.svelte';
let { node, position, selected, dropTarget, movable, port, linkCount, zoneNames, onpress, onconnect, onselect, onhover } = $props();
let { node, position, selected, dropTarget, movable, port, linkCount, zoneNames, serverDown = false, statusText = false, onpress, onconnect, onselect, onhover } = $props();
let card;
let portDot = $state();
// Same as the deploy canvas drop target: accent border and a soft 4px ring.
const DROP_TARGET = 'border-coollabs! dark:border-warning! ring-4 ring-coollabs/15 dark:ring-warning/15';
const extra = $derived(node.extra_zones?.length || 0);
const volumes = $derived(node.volumes || []);
const children = $derived(node.children || []);
const chip = 'inline-flex h-5 max-w-[9rem] items-center truncate rounded-full border border-neutral-200 bg-neutral-50 px-1.5 text-[10px] font-medium text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.05] dark:text-fg-dim';
const stop = e => e.stopPropagation();
// The server is not reachable, so the saved status is stale: show it as unknown, not as a failure.
const DOWN = 'Unknown (server not reachable)';
const status = $derived(serverDown ? 'unknown' : node.status);
const TONE_TEXT = {
ok: 'text-emerald-600 dark:text-emerald-400',
warn: 'text-warning-700 dark:text-warning',
busy: 'text-warning-700 dark:text-warning',
bad: 'text-red-600 dark:text-red-400',
idle: 'text-neutral-500 dark:text-fg-faint',
};
</script>
<!-- data-ec-open: its page opens on double-click (see the root's pointerdown capture listener). -->
@@ -35,7 +44,7 @@
tabindex="0"
aria-label={isSource(node)
? `${node.name} (${node.missing ? 'deleted ' : ''}${TYPE_LABELS[node.type].toLowerCase()})`
: `${node.name} (${TYPE_LABELS[node.type] || node.type}), ${statusLabel(node.status)}`}
: `${node.name} (${TYPE_LABELS[node.type] || node.type}), ${serverDown ? DOWN : statusLabel(node.status)}`}
onpointerdown={e => onpress(e, card)}
onkeydown={e => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -64,7 +73,27 @@
title={node.missing ? 'This shared variable was deleted, but resources still reference it' : `Used by ${linkCount} ${linkCount === 1 ? 'resource' : 'resources'}`}
>{node.missing ? 'Deleted' : String(linkCount)}</span>
{:else}
<div class="pt-1"><StatusDot status={node.status} /></div>
{#if node.deployment}
<!-- Dashboard canvas: the running or queued deployment of the application. -->
<a
class="relative mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-full border border-neutral-200 bg-neutral-50 px-1.5 text-[10px] leading-4 font-medium text-neutral-600 hover:border-neutral-300 hover:text-black dark:border-white/[0.1] dark:bg-white/[0.05] dark:text-fg-dim dark:hover:border-white/[0.2] dark:hover:text-fg"
href={node.deployment.link}
title={node.deployment.pull_request_id ? `Pull request #${node.deployment.pull_request_id}` : 'Open the deployment'}
onpointerdown={stop}
onclick={stop}
>
<StatusDot status={node.deployment.status} size="size-1.5" />
{node.deployment.status === 'queued' ? 'Queued' : 'Deploying'}
</a>
{/if}
{#if statusText}
<!-- Server view: the status in words, so a stopped resource reads as stopped at a glance. -->
<span class="mt-0.5 inline-flex shrink-0 items-center gap-1.5 text-[11px] leading-4 font-medium {TONE_TEXT[statusTone(status)]}" title={serverDown ? DOWN : statusLabel(status)}>
{statusWord(status)}<StatusDot {status} size="size-1.5" title={serverDown ? DOWN : null} />
</span>
{:else}
<div class="pt-1"><StatusDot {status} title={serverDown ? DOWN : null} /></div>
{/if}
{/if}
</div>
@@ -80,11 +109,9 @@
>{node.urls[0].replace(/^https?:\/\//, '')}</a>
{/if}
{#if extra || volumes.length}
{#if extra}
<div class="mt-2 flex min-w-0 flex-wrap gap-1 overflow-hidden h-5">
{#if extra}<span class={chip} title={zoneNames.join(', ')}>+{extra} {extra === 1 ? 'server' : 'servers'}</span>{/if}
{#each volumes.slice(0, MAX_VOLUMES) as v, i (i)}<span class={chip} title="Volume {v}">{v}</span>{/each}
{#if volumes.length > MAX_VOLUMES}<span class={chip} title={volumes.slice(MAX_VOLUMES).join(', ')}>+{volumes.length - MAX_VOLUMES}</span>{/if}
<span class={chip} title={zoneNames.join(', ')}>+{extra} {extra === 1 ? 'server' : 'servers'}</span>
</div>
{/if}
@@ -92,7 +119,7 @@
<ul class="mt-2 border-t border-neutral-100 pt-1.5 dark:border-white/[0.06]">
{#each children.slice(0, MAX_CHILDREN) as child, i (i)}
<li class="flex h-5 items-center gap-2 text-[12px] leading-5">
<StatusDot status={child.status} size="size-1.5" />
<StatusDot status={serverDown ? 'unknown' : child.status} size="size-1.5" title={serverDown ? DOWN : null} />
<span class="min-w-0 truncate">{child.name}</span>
</li>
{/each}
@@ -0,0 +1,86 @@
<script>
// Dashboard canvas: running, queued, and recent deployments. A row shows its application card;
// the status opens the deployment. Folded or open is remembered in localStorage.
import { TONE_DOT } from '../../environment-canvas.js';
import Icon from './Icon.svelte';
let { deployments, nodeById, onshow } = $props();
const KEY = 'coolify-canvas-deployments-open';
const STATUS = {
in_progress: ['In progress', 'busy'],
queued: ['Queued', 'busy'],
finished: ['Success', 'ok'],
failed: ['Failed', 'bad'],
'cancelled-by-user': ['Cancelled', 'idle'],
};
const status = value => STATUS[value] || [String(value || 'Unknown').replace(/[-_]/g, ' '), 'idle'];
let open = $state(readOpen());
function readOpen() {
try {
return localStorage.getItem(KEY) !== 'false';
} catch {
return true;
}
}
function toggle() {
open = !open;
try {
localStorage.setItem(KEY, String(open));
} catch {
// Storage blocked: the panel opens next time.
}
}
// "3m ago"; re-rendered with each canvas reload.
const units = [['d', 86400], ['h', 3600], ['m', 60]];
function ago(iso) {
const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
for (const [unit, size] of units) {
if (seconds >= size) return `${Math.floor(seconds / size)}${unit} ago`;
}
return 'just now';
}
</script>
<section class="flex max-h-80 w-72 min-w-0 flex-col overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-white/[0.08] dark:bg-panel" data-ec-deployments>
<button type="button" class="flex w-full shrink-0 items-center gap-2 px-3 py-2.5 text-left" aria-expanded={open} onclick={toggle}>
<span class="min-w-0 flex-1 text-[12px] font-semibold text-black dark:text-fg">Recent deployments</span>
<span class="text-[11px] text-neutral-400 dark:text-fg-faint">{deployments.length}</span>
<Icon name="chevron" class="size-3.5 text-neutral-400 transition-transform dark:text-fg-faint {open ? '' : 'rotate-180'}" />
</button>
{#if open}
<ul class="min-h-0 overflow-y-auto border-t border-neutral-100 dark:border-white/[0.06]">
{#each deployments as deployment (deployment.id)}
{@const [label, tone] = status(deployment.status)}
{@const node = deployment.application ? nodeById(deployment.application) : null}
<li class="flex items-center gap-2 border-b border-neutral-100 px-3 py-2 last:border-b-0 dark:border-white/[0.06]">
<button
type="button"
class="min-w-0 flex-1 text-left disabled:cursor-default enabled:hover:[&>p:first-child]:underline"
disabled={!node}
title={node ? 'Show on the canvas' : undefined}
onclick={() => onshow(node.id)}
>
<p class="truncate text-[12px] font-medium text-black underline-offset-2 dark:text-fg">{deployment.name}</p>
<p class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{deployment.pull_request_id ? `PR #${deployment.pull_request_id} · ` : ''}{deployment.created_at ? ago(deployment.created_at) : deployment.server}
</p>
</button>
<a
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-50 px-2 text-[10px] leading-5 font-medium text-neutral-600 hover:border-neutral-300 hover:text-black dark:border-white/[0.1] dark:bg-white/[0.05] dark:text-fg-dim dark:hover:border-white/[0.2] dark:hover:text-fg"
href={deployment.link}
title="Open the deployment"
>
<span class="relative flex size-1.5">
{#if tone === 'busy'}<span class="absolute inline-flex size-full rounded-full opacity-75 motion-safe:animate-ping {TONE_DOT.busy}"></span>{/if}
<span class="relative inline-flex size-full rounded-full {TONE_DOT[tone]}"></span>
</span>
{label}
</a>
</li>
{/each}
</ul>
{/if}
</section>
@@ -6,7 +6,7 @@
// The server canvas (data-endpoint) reuses this view. It saves moves and links on the environment canvas of each card.
// The shared variables and tags canvases (data-mode) reuse it too: their links add a variable or a tag to a resource.
import { flushSync, onMount, tick } from 'svelte';
import { api, BOX_GAP, freeSpot, MIN_ZOOM, panZoom } from '../../canvas-core.js';
import { api, BOX_GAP, freeSpot, panZoom } from '../../canvas-core.js';
import {
canConnect,
canLink,
@@ -16,8 +16,10 @@
dragRules,
filterGraph,
filterOptions,
fitView,
groupByUse,
groupFrames,
groupKey,
isSource,
layoutGraph,
layoutServerView,
@@ -25,13 +27,16 @@
MOBILE_NOTICE_KEY,
readView,
STATUS_CHANNELS,
unreachableZones,
zoneBoxes,
zoneGroups,
} from '../../environment-canvas.js';
import Card from './Card.svelte';
import Deployments from './Deployments.svelte';
import Icon from './Icon.svelte';
import Panel from './Panel.svelte';
import Toolbar from './Toolbar.svelte';
import CanvasControls from '../CanvasControls.svelte';
import Zone from './Zone.svelte';
// The page's [data-environment-canvas] element; it does not change while the canvas is mounted.
@@ -78,11 +83,12 @@
const interactions = new Set(); // active drags; refetches wait until they end
let deferred = null;
const unsaved = {};
let offsets = {}; // server view: card shift from its environment canvas position (see layoutServerView)
let reloadQueued = false;
let fitted = false;
let destroyed = false;
let containers = null; // server canvas: unmanaged container cards, loaded after the canvas renders
let usage = $state({}); // server canvas: latest CPU, memory and disk usage per server zone
const USAGE_POLL_MS = 30000;
const timers = { save: 0, refresh: 0, toast: 0, view: 0 };
// Zoom and pan are kept per team and page, so a reload opens the canvas where it was.
const viewKey = `coolify-canvas-view:${ds.team || ''}:${window.location.pathname}`;
@@ -97,10 +103,11 @@
let pz;
const canEdit = $derived(!!graph?.can_edit);
// Only the environment canvas saves card positions; server, project and connect views are auto-placed.
const canMove = $derived(canEdit && graph?.can_move !== false);
// Server view: unmanaged containers have no environment canvas, and zone frames are laid out, not saved.
const canMoveNode = node => canMove && (!serverView || !!node?.environment);
const canMoveZones = $derived(canMove && !serverView);
// Server canvas: admins drag whole project / environment groups; each environment saves its group position.
const canMoveGroups = $derived(canEdit && !!graph?.can_move_groups);
let groupMeta = {}; // server view: frame top-left and zone inner top-left per group key (see layoutServerView)
// Environment canvas endpoints of a card (the server view has no endpoints of its own for changes).
const environmentBase = node => (serverView
? `/projects/${encodeURIComponent(node?.project?.uuid || '')}/environments/${encodeURIComponent(node?.environment?.uuid || '')}/canvas`
@@ -123,6 +130,7 @@
.filter(p => !filter.project || p.uuid === filter.project)
.flatMap(p => p.environments.map(e => ({ uuid: e.uuid, name: filter.project ? e.name : `${p.name} / ${e.name}` }))));
const boxes = $derived(graph ? zoneBoxes(graph, positions, sizes) : []);
const downZones = $derived(unreachableZones(graph));
// Server and connect views: project / environment frames inside the zones.
const frames = $derived(graph?.layout === 'server' || graph?.layout === 'connect'
? zoneGroups(graph).flatMap(zone => groupFrames(zone.nodes.filter(n => n.context), positions, sizes))
@@ -197,26 +205,34 @@
// Floating nav: the canvas fills the screen behind the top bar and the sidebar (see app.css),
// so fit and the side panel use only the part that the floating panels do not cover.
function coveredInsets() {
if (!floatingBar()) return { top: 0, left: 0 };
const gap = 8;
const v = viewport.getBoundingClientRect();
// The bottom-left controls and deployments sit below the cards; fit keeps no side space for them.
const deploymentsRight = 0;
if (!floatingBar()) return { top: 0, left: Math.max(0, deploymentsRight) };
// The main sidebar, and a page's fixed settings rail (server canvas) when it is on the left.
const rail = document.querySelector('.application-settings-navigation');
const panels = [document.querySelector('.app-sidebar > div > nav'), rail && getComputedStyle(rail).position === 'fixed' ? rail : null]
.map(el => el?.getBoundingClientRect())
.filter(r => r?.width);
// The toolbar is in the top bar row, or in a row below it when the top bar has no room (see alignFloatingBar).
const barBottom = bar?.isConnected ? bar.getBoundingClientRect().bottom : 0;
const barBottom = bar?.isConnected && !inControls() ? bar.getBoundingClientRect().bottom : 0;
return {
top: Math.max(0, Math.max(topbar.getBoundingClientRect().bottom, barBottom) + gap - v.top),
left: Math.max(0, ...panels.map(r => r.right + gap - v.left)),
left: Math.max(0, deploymentsRight, ...panels.map(r => r.right + gap - v.left)),
};
}
/** Desktop: the toolbar sits in the zoom controls bar (bottom left), not in its own panel. */
const inControls = () => !!bar?.closest('[data-canvas-controls]');
function alignFloatingBar() {
if (!floatingBar()) {
panelTop = '';
return;
}
if (inControls()) {
panelTop = `${coveredInsets().top}px`;
return;
}
const gap = 8;
const area = bodyEl.getBoundingClientRect();
const head = topbar.getBoundingClientRect();
@@ -232,16 +248,13 @@
panelTop = `${coveredInsets().top}px`;
}
function placeBar() {
// data-toolbar-inline: the page docks its own actions in the top bar, so the toolbar stays in the canvas.
const slot = desktop.matches && !floatingBar() && !('toolbarInline' in ds) && document.getElementById('page-toolbar-slot');
// Desktop (every nav style): the toolbar joins the zoom controls bar at the bottom left.
// Small screens: a rounded box above the canvas.
bar.removeAttribute('style');
if (floatingBar()) {
bar.className = 'fixed z-40 flex flex-wrap items-center justify-center gap-3 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 dark:border-white/[0.08] dark:bg-panel';
bodyEl.append(bar);
if (desktop.matches) {
bar.className = 'ml-0.5 flex items-center gap-2 border-l border-neutral-200 pr-0.5 pl-2 dark:border-white/[0.08]';
rootEl.querySelector('[data-canvas-controls]').append(bar);
alignFloatingBar();
} else if (slot) {
bar.className = 'flex items-center gap-3 pl-3';
slot.append(bar);
} else {
bar.className = 'flex shrink-0 flex-wrap items-center gap-3 rounded-lg border border-neutral-200 bg-white px-3 py-2.5 dark:border-white/[0.08] dark:bg-panel';
barSlot.append(bar);
@@ -285,14 +298,14 @@
root: node.closest('[data-ec-root]'),
viewport: node,
world: node.firstElementChild,
zoomLabel: {}, // the toolbar shows `zoom`
zoomLabel: {}, // CanvasControls shows `zoom`
getView: () => view,
onChange: () => {
zoom = view.z;
clearTimeout(timers.view);
timers.view = setTimeout(saveView, 300);
},
canPan: e => !e.target.closest('[data-ec-card],[data-ec-zone-header],[data-ec-label],[data-ec-link],a,button,input'),
canPan: e => !e.target.closest('[data-ec-card],[data-ec-zone-header],[data-ec-group-header],[data-ec-label],[data-ec-link],a,button,input'),
onPanEnd: moved => {
if (!moved) closePanel();
},
@@ -306,7 +319,8 @@
// Storage full or blocked: the canvas fits to the screen next time.
}
}
function fit() {
/** Shows every card; the first fit of a load keeps them readable (see fitView). */
function fit({ readable = false } = {}) {
const all = graph ? zoneBoxes(graph, positions, sizes) : [];
const r = viewport.getBoundingClientRect();
const inset = coveredInsets();
@@ -315,15 +329,13 @@
pz.apply();
return;
}
const minX = Math.min(...all.map(b => b.x));
const minY = Math.min(...all.map(b => b.y));
const maxX = Math.max(...all.map(b => b.x + b.width));
const maxY = Math.max(...all.map(b => b.y + b.height));
const pad = 48;
const width = r.width - inset.left;
const height = r.height - inset.top;
const z = Math.min(1.1, Math.max(MIN_ZOOM, Math.min((width - pad * 2) / (maxX - minX), (height - pad * 2) / (maxY - minY))));
view = { z, x: inset.left + (width - (maxX - minX) * z) / 2 - minX * z, y: inset.top + (height - (maxY - minY) * z) / 2 - minY * z };
const bounds = {
minX: Math.min(...all.map(b => b.x)),
minY: Math.min(...all.map(b => b.y)),
maxX: Math.max(...all.map(b => b.x + b.width)),
maxY: Math.max(...all.map(b => b.y + b.height)),
};
view = fitView(bounds, { left: inset.left, top: inset.top, width: r.width - inset.left, height: r.height - inset.top }, readable);
pz.apply();
}
@@ -351,6 +363,16 @@
function select(selection) {
selected = selection;
}
/** Dashboard deployment panel: centres the card at 100% and selects it. */
function showNode(id) {
const at = positions[id];
if (!at) return;
const size = sizes[id] || { width: 260, height: 100 };
const r = viewport.getBoundingClientRect();
view = { z: 1, x: r.width / 2 - at.x - size.width / 2, y: r.height / 2 - at.y - size.height / 2 };
pz.apply();
select({ kind: 'node', id });
}
function closePanel() {
if (selected) select(null);
}
@@ -453,7 +475,7 @@
begin(`card:${id}`);
track(card, ev => {
if (!moved && Math.hypot(ev.clientX - start.cx, ev.clientY - start.cy) < 4) return;
if (!canMoveNode(nodeById(id))) return;
if (!canMove) return;
const rect = rectOf(id);
const wanted = { x: start.x + (ev.clientX - start.cx) / view.z, y: start.y + (ev.clientY - start.cy) / view.z, w: rect.width, h: rect.height };
// Cards cannot cover each other or another zone: stop at the edge of what is in the way.
@@ -469,7 +491,7 @@
}
function startZoneDrag(box, header, e) {
if (e.button !== 0 || !canMoveZones || e.target.closest('a')) return;
if (e.button !== 0 || !canMove || e.target.closest('a')) return;
e.stopPropagation();
const ids = box.nodes.filter(id => positions[id]);
const starts = Object.fromEntries(ids.map(id => [id, { ...positions[id] }]));
@@ -494,6 +516,49 @@
});
}
/** Server view: drag a project / environment group inside its server zone; it cannot cover another group. */
function startGroupDrag(frame, header, e) {
if (e.button !== 0 || !canMoveGroups || !frame.key || !groupMeta[frame.key] || e.target.closest('a')) return;
e.stopPropagation();
const meta = groupMeta[frame.key];
const ids = graph.nodes.filter(n => groupKey([n], n.zone) === frame.key).map(n => n.id);
const starts = Object.fromEntries(ids.map(id => [id, { ...positions[id] }]));
const box = toBox(frame);
const others = frames.filter(f => f !== frame && f.zone === frame.zone).map(toBox);
const fits = b => b.x >= meta.inner.x && b.y >= meta.inner.y;
const origin = { cx: e.clientX, cy: e.clientY };
let moved = false;
capture(header, e.pointerId);
begin('group');
track(header, ev => {
if (!moved && Math.hypot(ev.clientX - origin.cx, ev.clientY - origin.cy) < 4) return;
const wanted = { ...box, x: Math.max(meta.inner.x, box.x + (ev.clientX - origin.cx) / view.z), y: Math.max(meta.inner.y, box.y + (ev.clientY - origin.cy) / view.z) };
const spot = freeSpot(wanted, others, BOX_GAP, fits);
if (!spot) return;
moved = true;
for (const id of ids) positions[id] = { x: starts[id].x + spot.x - box.x, y: starts[id].y + spot.y - box.y };
}, () => {
end('group');
if (moved) saveGroup(ids, meta.shift);
});
}
/**
* Saves the cards of a moved group on their environment canvas (the server canvas has no positions of its own),
* so the group keeps its place here and moves by the same distance on the environment canvas.
*/
async function saveGroup(ids, shift) {
const saved = Object.fromEntries(ids.map(id => [id, { x: Math.round(positions[id].x - shift.x), y: Math.round(positions[id].y - shift.y) }]));
try {
await api('PATCH', `${environmentBase(nodeById(ids[0]))}/positions`, { positions: saved });
if (!destroyed) applyGraph({ ...full, nodes: full.nodes.map(n => (saved[n.id] ? { ...n, saved: saved[n.id] } : n)) });
} catch (e) {
if (destroyed) return;
toast(e.message, true);
load({ quiet: true });
}
}
function startConnect(id, port, e) {
if (e.button !== 0 || !canEdit) return;
e.stopPropagation();
@@ -573,18 +638,8 @@
}
// ── Persistence ──
/**
* Server view: environment canvas positions of the moved cards and of the other cards of their group,
* so the whole group keeps its arrangement on the next layout.
*/
function environmentPositions(ids) {
const moved = ids.map(nodeById).filter(n => n?.environment);
const members = (graph?.nodes || []).filter(n => offsets[n.id] && positions[n.id]
&& moved.some(m => m.zone === n.zone && m.environment.uuid === n.environment?.uuid));
return Object.fromEntries(members.map(n => [n.id, { x: positions[n.id].x - offsets[n.id].x, y: positions[n.id].y - offsets[n.id].y }]));
}
function queueSave(map) {
Object.assign(unsaved, serverView ? environmentPositions(Object.keys(map)) : map);
Object.assign(unsaved, map);
clearTimeout(timers.save);
timers.save = setTimeout(() => flushSave(), 400);
}
@@ -597,14 +652,8 @@
const forget = () => {
for (const id of ids) if (unsaved[id] === batch[id]) delete unsaved[id];
};
// Server view: one request per environment canvas.
const requests = new Map();
for (const id of ids) {
const path = environmentBase(full?.nodes.find(n => n.id === id));
requests.set(path, { ...requests.get(path), [id]: rounded[id] });
}
try {
await Promise.all([...requests].map(([path, part]) => api('PATCH', `${path}/positions`, { positions: part }, options)));
await api('PATCH', `${base}/positions`, { positions: rounded }, options);
if (graph && !destroyed) patchGraph({ positions: { ...(graph.positions || {}), ...rounded } });
forget();
} catch (e) {
@@ -635,8 +684,8 @@
next.positions = { ...next.positions, ...Object.fromEntries(Object.entries(unsaved).map(([id, p]) => [id, { x: p.x, y: p.y }])) };
full = next;
const shown = groupByUse(filterGraph(next, filter));
offsets = {};
positions = shown.layout === 'server' ? layoutServerView(shown, offsets) : layoutGraph(shown);
groupMeta = {};
positions = shown.layout === 'server' ? layoutServerView(shown, groupMeta) : layoutGraph(shown);
graph = shown;
error = null;
if (hover && !nodeById(hover)) hover = null;
@@ -649,7 +698,7 @@
fitted = true;
// The first load keeps a saved view; later refits (filter changes) fit again.
if (restoredView) restoredView = null;
else fit();
else fit({ readable: true });
// Connect view: ?select=<card id> (from a variable's or tag's connect button) opens that card.
const wanted = connectView && params.get('select');
if (wanted && !selected && nodeById(wanted)) select({ kind: 'node', id: wanted });
@@ -671,6 +720,7 @@
if (!quiet && fitted && next) toast('Canvas refreshed.');
// Status refreshes keep the containers; the first load and the Refresh button read them again.
if (next?.containers_endpoint && (!quiet || !containers)) loadContainers(next.containers_endpoint);
if (!quiet || !Object.keys(usage).length) loadUsage();
} catch (e) {
if (destroyed) return;
if (graph) toast(e.message, true);
@@ -697,6 +747,21 @@
}
}
/** Server canvas: live usage per server, after the canvas renders (Sentinel answers through SSH). */
function loadUsage() {
for (const zone of full?.zones || []) {
const path = zone.details?.usage_endpoint;
if (!path) continue;
api('GET', path)
.then(data => {
if (!destroyed && data) usage = { ...usage, [zone.id]: data };
})
.catch(() => {
// Usage is optional; the header shows the other server details.
});
}
}
function scheduleRefresh(delay = 1000) {
clearTimeout(timers.refresh);
timers.refresh = setTimeout(() => load({ quiet: true }), delay);
@@ -730,6 +795,19 @@
}
}
// data-poll (seconds): reload while the page is visible, for changes without a broadcast event (dashboard deployments).
if (Number(ds.poll) > 0) {
const poll = setInterval(() => {
if (document.visibilityState === 'visible') load({ quiet: true });
}, Number(ds.poll) * 1000);
cleanups.push(() => clearInterval(poll));
}
const usagePoll = setInterval(() => {
if (document.visibilityState === 'visible') loadUsage();
}, USAGE_POLL_MS);
cleanups.push(() => clearInterval(usagePoll));
pz.apply();
load({ quiet: true });
@@ -759,20 +837,14 @@
<!-- The toolbar's place above the canvas; use:dock moves it into the top bar when there is room. -->
<div bind:this={barSlot} class="contents">
<Toolbar
title={ds.title}
viewOnly={!!graph && !canEdit}
{viewOnlyTitle}
{filter}
{projects}
{environments}
{loading}
{zoom}
{views}
{dock}
onfilter={setFilter}
onrefresh={() => load()}
onzoom={z => pz.zoomTo(z)}
onfit={fit}
/>
</div>
@@ -796,14 +868,21 @@
<div class="absolute top-0 left-0 origin-top-left">
<div class="absolute top-0 left-0">
{#each boxes as box (box.id ?? '__none')}
<Zone {box} movable={canMoveZones} onpress={(e, header) => startZoneDrag(box, header, e)} />
<Zone {box} movable={canMove} nodes={box.nodes.map(nodeById).filter(Boolean)} usage={usage[box.id]} onpress={(e, header) => startZoneDrag(box, header, e)} />
{/each}
</div>
<!-- Server view: project / environment frames inside the server zones. -->
<div class="pointer-events-none absolute top-0 left-0">
{#each frames as frame, i (i)}
{@const movableGroup = canMoveGroups && !!frame.key}
<div class="absolute rounded-xl border border-dashed border-neutral-300 dark:border-white/[0.12]" style:left="{frame.x}px" style:top="{frame.y}px" style:width="{frame.width}px" style:height="{frame.height}px">
<div class="flex h-8 items-center gap-1.5 px-3 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex h-8 items-center gap-1.5 px-3 text-[11px] font-medium text-neutral-500 dark:text-fg-dim {movableGroup ? 'pointer-events-auto cursor-grab active:cursor-grabbing' : ''}"
data-ec-group-header
title={movableGroup ? 'Drag to move this group' : undefined}
onpointerdown={e => startGroupDrag(frame, e.currentTarget, e)}
>
<Icon name="service" class="size-3 text-neutral-400 dark:text-fg-faint" />
<a class="pointer-events-auto truncate hover:text-neutral-700 dark:hover:text-fg [&[href]]:cursor-pointer [&[href]]:underline-offset-2 [&[href]]:hover:underline" href={frame.link || undefined} draggable="false">{frame.name || 'Other'}</a>
</div>
@@ -859,12 +938,14 @@
position={positions[node.id]}
selected={selected?.kind === 'node' && selected.id === node.id}
dropTarget={dropTarget === node.id}
movable={canMoveNode(node)}
movable={canMove}
port={canEdit && (connectView ? isSource(node) && !node.missing : !serverView || !!node.environment)
? (connectView ? text.port : 'Drag to another card to draw a link')
: null}
linkCount={linkCounts[node.id] || 0}
zoneNames={zoneNames(node)}
serverDown={downZones.has(node.zone)}
statusText={graph?.layout === 'server'}
onpress={(e, card) => startCardDrag(node.id, card, e)}
onconnect={(e, port) => startConnect(node.id, port, e)}
onselect={() => select({ kind: 'node', id: node.id })}
@@ -909,6 +990,14 @@
</div>
{/if}
<!-- Bottom left: zoom controls, then the dashboard's recent deployments next to them. -->
<div class="pointer-events-none absolute bottom-3 left-3 right-3 z-20 flex items-end gap-2 *:pointer-events-auto" data-ec-dock>
<CanvasControls inline {zoom} {loading} onzoom={z => pz.zoomTo(z)} onfit={() => fit()} onrefresh={() => load()} />
{#if graph?.deployments?.length}
<Deployments deployments={graph.deployments} {nodeById} onshow={showNode} />
{/if}
</div>
{#if graph && (panelNode || panelLink)}
{#key `${selected.kind}:${selected.id}`}
<Panel
@@ -246,14 +246,6 @@
{/snippet}
{@render section('Containers', containers)}
{/if}
{#if node.volumes?.length}
{#snippet volumes()}
<div class="flex flex-wrap gap-1">
{#each node.volumes as v, i (i)}<span class="max-w-full truncate rounded-full border border-neutral-200 bg-neutral-50 px-2 py-0.5 text-[11px] text-neutral-600 dark:border-white/[0.1] dark:bg-white/[0.05] dark:text-fg-dim">{v}</span>{/each}
</div>
{/snippet}
{@render section('Volumes', volumes)}
{/if}
{#snippet linkList()}
<ul class="flex flex-col gap-0.5">
{#if !nodeLinks.length}<li class={faint}>{canEdit ? 'No links. Drag the dot on the right edge of a card to another card.' : 'No links.'}</li>{/if}
@@ -1,11 +1,11 @@
<script>
import { statusLabel, statusTone, TONE_DOT } from '../../environment-canvas.js';
let { status, size = 'size-2' } = $props();
let { status, size = 'size-2', title = null } = $props();
const tone = $derived(statusTone(status));
</script>
<span class="relative flex {size} shrink-0" title={statusLabel(status)}>
<span class="relative flex {size} shrink-0" title={title || statusLabel(status)}>
{#if tone === 'busy'}
<span class="absolute inline-flex size-full rounded-full opacity-75 motion-safe:animate-ping {TONE_DOT.busy}"></span>
{/if}
+3 -15
View File
@@ -1,9 +1,8 @@
<script>
// Title, filters, refresh and zoom. The root docks it (use:dock) into the app top bar on desktop,
// or in a rounded box above the canvas on small screens.
import Icon from './Icon.svelte';
// Filters and view toggles. The root docks it (use:dock) into the zoom controls bar
// (CanvasControls, bottom left) on desktop, or in a rounded box above the canvas on small screens.
let { title, viewOnly, viewOnlyTitle, filter, projects, environments, loading, zoom, views, dock, onfilter, onrefresh, onzoom, onfit } = $props();
let { viewOnly, viewOnlyTitle, filter, projects, environments, views, dock, onfilter } = $props();
const select = 'select h-8! w-44 py-0! text-[12px]! max-lg:w-auto max-lg:min-w-0 max-lg:flex-1 max-lg:basis-32';
const value = (options, wanted) => (options.some(o => o.uuid === wanted) ? wanted : '');
@@ -18,7 +17,6 @@
<div use:dock data-ec-toolbar>
<div class="flex min-w-0 items-center gap-2">
{#if title}<b class="shrink-0 text-[14px] font-semibold text-black dark:text-fg">{title}</b>{/if}
<span class="inline-flex items-center">
<span class="px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-coollabs/10 text-coollabs border border-coollabs/30 dark:bg-warning/15 dark:text-warning dark:border-warning/30">Beta</span>
</span>
@@ -37,15 +35,5 @@
<option value="">All environments</option>
{#each environments as o (o.uuid)}<option value={o.uuid}>{o.name}</option>{/each}
</select>
<button type="button" class="button" title="Load the latest status" disabled={loading} onclick={onrefresh}>
<Icon name="refresh" class="size-3.5 {loading ? 'animate-spin' : ''}" />
<span class="hidden sm:inline">Refresh</span>
</button>
<div class="flex items-center">
<button type="button" class="button px-2! rounded-r-none!" title="Zoom out" aria-label="Zoom out" onclick={() => onzoom(zoom / 1.2)}><Icon name="minus" /></button>
<button type="button" class="button rounded-none! border-x-0! w-14 tabular-nums" title="Reset zoom to 100%" onclick={() => onzoom(1)}>{Math.round(zoom * 100)}%</button>
<button type="button" class="button px-2! rounded-l-none!" title="Zoom in" aria-label="Zoom in" onclick={() => onzoom(zoom * 1.2)}><Icon name="plus" /></button>
</div>
<button type="button" class="button px-2!" title="Fit to screen" aria-label="Fit to screen" onclick={onfit}><Icon name="fit" />Fit</button>
</div>
</div>
+81 -13
View File
@@ -1,32 +1,100 @@
<script>
// A zone frame (server, or a group of the connect view) with its header; the header drags the zone.
import { NO_SERVER } from '../../environment-canvas.js';
// Server canvas zones (zone.details) add a second row: host facts, proxy, live usage and a status summary.
import { formatMemory, formatUptime, NO_SERVER, statusSummary, zoneHeader } from '../../environment-canvas.js';
import Icon from './Icon.svelte';
let { box, movable, onpress } = $props();
let { box, movable, onpress, nodes = [], usage = null } = $props();
let header;
// The server IP stays hidden until the user asks for it (screen sharing, screenshots).
let showIp = $state(false);
const zone = $derived(box.zone);
const ok = $derived(zone?.is_reachable && zone?.is_usable);
const title = $derived(movable ? 'Drag to move every card in this zone' : !zone ? 'Resources without a server' : undefined);
const details = $derived(zone?.details);
const host = $derived(details
? [details.os, details.arch, details.cpus ? `${details.cpus} ${details.cpus === 1 ? 'CPU' : 'CPUs'}` : null, formatMemory(details.memory_bytes), details.uptime_since && formatUptime(details.uptime_since) ? `up ${formatUptime(details.uptime_since)}` : null].filter(Boolean)
: []);
const proxyRunning = $derived(details?.proxy?.status === 'running');
const summary = $derived(details && zone?.is_reachable ? statusSummary(nodes) : null);
const SUMMARY = [
['ok', 'running', 'bg-emerald-500'],
['busy', 'starting', 'bg-warning'],
['warn', 'degraded', 'bg-warning'],
['bad', 'stopped', 'bg-red-500'],
['idle', 'unknown', 'bg-neutral-400 dark:bg-neutral-500'],
];
const USAGE = [['cpu', 'CPU'], ['memory', 'Memory'], ['disk', 'Disk']];
const usageTone = value => (value >= 90 ? 'text-red-600 dark:text-red-400' : value >= 75 ? 'text-warning-700 dark:text-warning' : 'text-neutral-600 dark:text-fg-dim');
const sep = 'h-3 w-px shrink-0 bg-neutral-200 dark:bg-white/[0.1]';
</script>
<div
class="absolute rounded-2xl border border-neutral-200 bg-white/50 dark:border-white/[0.07] dark:bg-white/[0.02]"
class="absolute rounded-2xl border border-neutral-200 bg-[color-mix(in_oklab,var(--color-neutral-50),white_50%)] dark:border-white/[0.07] dark:bg-[color-mix(in_oklab,var(--color-app),var(--color-panel)_35%)]"
style:left="{box.x}px"
style:top="{box.y}px"
style:width="{box.width}px"
style:height="{box.height}px"
>
<div bind:this={header} class="flex h-10 items-center gap-2 px-4 text-[12px] {movable ? 'cursor-grab active:cursor-grabbing' : ''}" data-ec-zone-header {title} role="presentation" onpointerdown={e => onpress(e, header)}>
<Icon name={zone?.icon || 'server'} class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<a class="truncate font-semibold text-neutral-700 dark:text-fg [&[href]]:cursor-pointer [&[href]]:underline-offset-2 [&[href]]:hover:underline" href={zone?.link || undefined} draggable="false">{box.name || NO_SERVER}</a>
<!-- Zones with an icon are not server zones (connect view). -->
{#if zone && !zone.icon}
<span
class="size-1.5 shrink-0 rounded-full {ok ? 'bg-emerald-500' : zone.is_reachable ? 'bg-warning' : 'bg-red-500'}"
title={ok ? 'Reachable' : zone.is_reachable ? 'Reachable, not usable' : 'Not reachable'}
></span>
{#if zone.ip}<span class="truncate font-mono text-[11px] text-neutral-400 dark:text-fg-faint">{zone.ip}</span>{/if}
<div bind:this={header} class="px-4 {movable ? 'cursor-grab active:cursor-grabbing' : ''}" style:height="{zoneHeader(zone)}px" data-ec-zone-header {title} role="presentation" onpointerdown={e => onpress(e, header)}>
<div class="flex h-10 items-center gap-2 text-[12px]">
<Icon name={zone?.icon || 'server'} class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<a class="truncate font-semibold text-neutral-700 dark:text-fg [&[href]]:cursor-pointer [&[href]]:underline-offset-2 [&[href]]:hover:underline" href={zone?.link || undefined} draggable="false">{box.name || NO_SERVER}</a>
<!-- Zones with an icon are not server zones (connect view). -->
{#if zone && !zone.icon}
{#if ok}
<span class="size-1.5 shrink-0 rounded-full bg-emerald-500" title="Reachable"></span>
{:else}
<!-- Spelled out: the status of every card on this server is unknown while it is down. -->
<span
class="inline-flex h-5 shrink-0 items-center gap-1.5 rounded-full border px-2 text-[11px] font-medium {zone.is_reachable
? 'border-warning/40 bg-warning/10 text-warning-700 dark:text-warning'
: 'border-red-200 bg-red-50 text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400'}"
title={zone.is_reachable ? 'Coolify can connect to this server, but cannot use it (for example, Docker is missing)' : 'Coolify cannot connect to this server. The status of its resources is unknown.'}
>
<span class="size-1.5 rounded-full {zone.is_reachable ? 'bg-warning' : 'bg-red-500'}"></span>
{zone.is_reachable ? 'Not usable' : 'Unreachable'}
</span>
{/if}
{#if zone.ip}
<button
type="button"
class="truncate font-mono text-[11px] text-neutral-400 hover:text-neutral-600 dark:text-fg-faint dark:hover:text-fg-dim"
title={showIp ? 'Hide IP address' : 'Show IP address'}
onpointerdown={e => e.stopPropagation()}
onclick={() => (showIp = !showIp)}
>{showIp ? zone.ip : 'Show IP'}</button>
{/if}
{#if summary}
<span class="ml-auto flex shrink-0 items-center gap-2.5 text-[11px] text-neutral-500 dark:text-fg-dim" title="Resources on this server by status">
{#each SUMMARY as [tone, label, dot] (tone)}
{#if summary[tone]}
<span class="inline-flex items-center gap-1"><span class="size-1.5 rounded-full {dot}"></span>{summary[tone]} {label}</span>
{/if}
{/each}
</span>
{/if}
{/if}
</div>
{#if details}
<div class="flex h-5 min-w-0 items-center gap-2.5 overflow-hidden text-[11px] whitespace-nowrap text-neutral-500 dark:text-fg-faint">
{#if host.length}<span class="truncate" title="Operating system, architecture, CPUs, memory and uptime">{host.join(' · ')}</span>{/if}
{#if details.proxy}
{#if host.length}<span class={sep}></span>{/if}
<span class="inline-flex shrink-0 items-center gap-1.5" title="Proxy: {details.proxy.status || 'unknown'}">
<span class="size-1.5 rounded-full {proxyRunning ? 'bg-emerald-500' : details.proxy.status ? 'bg-red-500' : 'bg-neutral-400'}"></span>
{details.proxy.type.charAt(0) + details.proxy.type.slice(1).toLowerCase()}{proxyRunning ? '' : ` ${details.proxy.status || ''}`}
</span>
{/if}
{#if usage && USAGE.some(([key]) => usage[key] !== null && usage[key] !== undefined)}
<span class={sep}></span>
{#each USAGE as [key, label] (key)}
{#if usage[key] !== null && usage[key] !== undefined}
<span class="shrink-0" title="{label} usage (last minute)">{label} <span class="font-medium tabular-nums {usageTone(usage[key])}">{Math.round(usage[key])}%</span></span>
{/if}
{/each}
{/if}
</div>
{/if}
</div>
</div>
+137 -61
View File
@@ -3,7 +3,7 @@
//
// DOM-free: `node --test` imports this file.
import { apart, BOX_GAP, MAX_ZOOM, MIN_ZOOM } from './canvas-core.js';
import { apart, BOX_GAP, freeSpot, MAX_ZOOM, MIN_ZOOM } from './canvas-core.js';
export const CARD_WIDTH = 260;
export const COLUMN_GAP = 48;
@@ -11,8 +11,8 @@ export const ROW_GAP = 24;
export const ZONE_GAP = 72;
export const ZONE_PADDING = 24;
export const ZONE_HEADER = 40;
export const SERVER_ZONE_HEADER = 68; // server canvas: a second header row with host facts, proxy and usage
export const MAX_CHILDREN = 5;
export const MAX_VOLUMES = 3;
export const MAX_ROWS = 6; // auto placement wraps a type into another column after this many cards
export const NO_SERVER = 'No server';
export const DOUBLE_CLICK_MS = 400;
@@ -25,6 +25,8 @@ export const isSource = node => node?.type === 'variable' || node?.type === 'tag
const columnOf = node => COLUMNS[node?.type] ?? 0;
const columnLeft = (originX, column) => originX + column * (CARD_WIDTH + COLUMN_GAP);
const isPoint = p => !!p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y));
/** Header height of a zone frame: server canvas zones show a second row of server details. */
export const zoneHeader = zone => (zone?.details ? SERVER_ZONE_HEADER : ZONE_HEADER);
const storedPositions = graph => (graph?.positions && !Array.isArray(graph.positions) ? graph.positions : {});
// ── Pure helpers (tested) ─────────────────────────────────────────────
@@ -33,7 +35,7 @@ const storedPositions = graph => (graph?.positions && !Array.isArray(graph.posit
export function estimateNodeHeight(node) {
let height = 58; // padding + icon/name row
if (node?.urls?.length) height += 20;
if (node?.extra_zones?.length || node?.volumes?.length) height += 28;
if (node?.extra_zones?.length) height += 28;
const children = node?.children?.length || 0;
if (children) height += 12 + Math.min(children, MAX_CHILDREN) * 20 + (children > MAX_CHILDREN ? 20 : 0);
return height;
@@ -132,11 +134,26 @@ const contextGroups = nodes => {
const byTypeAndName = (a, b) => columnOf(a) - columnOf(b) || String(a.name).localeCompare(String(b.name));
/**
* Card positions inside one group, from (0, 0). Linked cards form chains from
* left to right (source before target, one column per step); every chain gets
* its own rows. Cards without links follow in a grid of SINGLE_COLUMNS.
* Card positions inside one group, from (0, 0). Cards with a position saved on their environment
* canvas (node.saved) keep that arrangement; the other cards follow below them. Otherwise linked
* cards form chains from left to right (source before target, one column per step); every chain
* gets its own rows. Cards without links follow in a grid of SINGLE_COLUMNS.
*/
export function layoutBlock(nodes, links = []) {
const saved = nodes.filter(n => isPoint(n.saved));
if (saved.length) {
const left = Math.min(...saved.map(n => Number(n.saved.x)));
const top = Math.min(...saved.map(n => Number(n.saved.y)));
const out = {};
for (const n of saved) out[n.id] = { x: Number(n.saved.x) - left, y: Number(n.saved.y) - top };
const width = Math.max(...saved.map(n => out[n.id].x)) + CARD_WIDTH;
const bottom = Math.max(...saved.map(n => out[n.id].y + estimateNodeHeight(n)));
const rest = nodes.filter(n => !out[n.id]);
if (!rest.length) return { positions: out, width, height: bottom };
const below = layoutBlock(rest, links);
for (const [id, p] of Object.entries(below.positions)) out[id] = { x: p.x, y: bottom + ROW_GAP + p.y };
return { positions: out, width: Math.max(width, below.width), height: bottom + ROW_GAP + below.height };
}
const ids = new Set(nodes.map(n => n.id));
const inside = links.filter(l => ids.has(l.source) && ids.has(l.target) && l.source !== l.target);
const parent = new Map(nodes.map(n => [n.id, n.id]));
@@ -200,70 +217,70 @@ export function layoutBlock(nodes, links = []) {
return { positions: out, width: columns * step - COLUMN_GAP, height: bottom };
}
/**
* A project / environment group with cards saved on its environment canvas:
* the same placement as that canvas (saved cards, then a grid below them),
* moved so the top-left card starts at (0, 0). Groups without saved cards use layoutBlock.
*/
export function layoutStoredBlock(nodes, stored, links = []) {
if (!nodes.some(n => isPoint(stored[n.id]))) return layoutBlock(nodes, links);
const zone = nodes[0].zone;
const placed = layoutGraph({ zones: [{ id: zone }], nodes: nodes.map(n => ({ ...n, zone })), positions: stored });
const minX = Math.min(...nodes.map(n => placed[n.id].x));
const minY = Math.min(...nodes.map(n => placed[n.id].y));
const positions = Object.fromEntries(nodes.map(n => [n.id, { x: placed[n.id].x - minX, y: placed[n.id].y - minY }]));
return {
positions,
origin: { x: minX, y: minY },
width: Math.max(...nodes.map(n => positions[n.id].x + CARD_WIDTH)),
height: Math.max(...nodes.map(n => positions[n.id].y + estimateNodeHeight(n))),
};
}
/** Server view: key of a project / environment group on one server ("<environment uuid>:<zone id>"). */
export const groupKey = (nodes, zoneId) => {
const environment = nodes.find(n => n.environment?.uuid)?.environment?.uuid;
return environment && zoneId ? `${environment}:${zoneId}` : null;
};
/**
* Top-left of the given cards where their environment canvas places them. A group saved for the first time
* from the server view goes there, so it does not cover the cards of the environment on other servers.
* Server view: zones stack downwards. A group with cards saved on its environment canvas (node.saved) sits where
* those cards are, so the server canvas and the environment canvas share one position (a group that would
* leave the zone or cover another group moves to the nearest free spot). Other groups follow below,
* GROUPS_PER_ROW per row. When `groups` is given, it gets per group key its frame top-left, the zone's inner
* top-left, and `shift`: environment canvas position = server view position - shift.
*/
function environmentOrigin(graph, nodes) {
const environment = nodes[0]?.environment?.uuid;
if (!environment) return { x: 0, y: 0 };
const placed = layoutGraph({
zones: graph.zones,
nodes: (graph.nodes || []).filter(n => n.environment?.uuid === environment),
positions: storedPositions(graph),
});
return { x: Math.min(...nodes.map(n => placed[n.id].x)), y: Math.min(...nodes.map(n => placed[n.id].y)) };
}
/**
* Server view: zones stack downwards; inside a zone, one block per project / environment, GROUPS_PER_ROW per row.
* When `offsets` is given, it gets per card the shift from its environment canvas position to its server view
* position, so a moved card can be saved on its environment canvas.
*/
export function layoutServerView(graph, offsets = null) {
const stored = storedPositions(graph);
export function layoutServerView(graph, groups = null) {
const out = {};
let y = 0;
for (const zone of zoneGroups(graph)) {
const innerTop = y + ZONE_HEADER + ZONE_PADDING;
const inner = { x: 0, y: y + zoneHeader(zone.zone) + ZONE_PADDING };
const blocks = contextGroups(zone.nodes).map(([, nodes]) => {
const saved = nodes.filter(n => isPoint(n.saved));
const block = layoutBlock(nodes, graph.links || []);
return {
key: groupKey(nodes, zone.id),
block,
size: { w: block.width + GROUP_PADDING * 2, h: GROUP_HEADER + GROUP_PADDING * 2 + block.height },
at: saved.length ? { x: Math.min(...saved.map(n => Number(n.saved.x))), y: Math.min(...saved.map(n => Number(n.saved.y))) } : null,
};
});
const pinned = blocks.filter(b => b.at).sort((a, b) => a.at.y - b.at.y || a.at.x - b.at.x);
// The environment canvas puts its first cards below a zone header; those land at the zone's top-left here.
const shift = { x: inner.x + GROUP_PADDING, y: inner.y + GROUP_HEADER + GROUP_PADDING - ZONE_HEADER - ZONE_PADDING };
const place = ({ key, block, size }, left, top) => {
for (const [id, p] of Object.entries(block.positions)) {
out[id] = { x: left + GROUP_PADDING + p.x, y: top + GROUP_HEADER + GROUP_PADDING + p.y };
}
if (groups && key) groups[key] = { x: left, y: top, inner, shift };
return top + size.h;
};
const taken = [];
const inside = b => b.x >= inner.x && b.y >= inner.y;
let bottom = inner.y;
for (const b of pinned) {
// Environment canvas positions can be left of or above the zone (negative coordinates): keep the group inside.
const wanted = {
x: Math.max(inner.x, b.at.x + shift.x - GROUP_PADDING),
y: Math.max(inner.y, b.at.y + shift.y - GROUP_HEADER - GROUP_PADDING),
...b.size,
};
const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted;
taken.push(spot);
bottom = Math.max(bottom, place(b, spot.x, spot.y));
}
let x = 0;
let rowTop = innerTop;
let rowBottom = innerTop;
contextGroups(zone.nodes).forEach(([, nodes], i) => {
let rowTop = pinned.length ? bottom + GROUP_GAP : inner.y;
let rowBottom = rowTop;
blocks.filter(b => !b.at).forEach((b, i) => {
if (i > 0 && i % GROUPS_PER_ROW === 0) {
x = 0;
rowTop = rowBottom + GROUP_GAP;
}
const block = layoutStoredBlock(nodes, stored, graph.links || []);
const origin = offsets ? (block.origin ?? environmentOrigin(graph, nodes)) : null;
for (const [id, p] of Object.entries(block.positions)) {
out[id] = { x: x + GROUP_PADDING + p.x, y: rowTop + GROUP_HEADER + GROUP_PADDING + p.y };
if (offsets) offsets[id] = { x: out[id].x - origin.x - p.x, y: out[id].y - origin.y - p.y };
}
x += block.width + GROUP_PADDING * 2 + GROUP_GAP;
rowBottom = Math.max(rowBottom, rowTop + GROUP_HEADER + GROUP_PADDING * 2 + block.height);
rowBottom = Math.max(rowBottom, place(b, x, rowTop));
x += b.size.w + GROUP_GAP;
});
y = rowBottom + ZONE_PADDING + ZONE_GAP;
y = Math.max(bottom, rowBottom) + ZONE_PADDING + ZONE_GAP;
}
return out;
}
@@ -326,6 +343,8 @@ export function groupFrames(nodes, positions, sizes = {}) {
const minY = Math.min(...rects.map(r => r.y));
frames.push({
name,
key: groupKey(members, members[0].zone),
zone: members[0].zone,
link: members[0].context_link,
x: minX - GROUP_PADDING,
y: minY - GROUP_PADDING - GROUP_HEADER,
@@ -366,9 +385,9 @@ export function zoneBoxes(graph, positions, sizes = {}) {
zone: group.zone,
nodes: group.nodes.map(n => n.id),
x: minX - ZONE_PADDING,
y: minY - ZONE_PADDING - ZONE_HEADER,
y: minY - ZONE_PADDING - zoneHeader(group.zone),
width: maxX - minX + ZONE_PADDING * 2,
height: maxY - minY + ZONE_PADDING * 2 + ZONE_HEADER,
height: maxY - minY + ZONE_PADDING * 2 + zoneHeader(group.zone),
});
}
return boxes;
@@ -399,7 +418,7 @@ export function dragRules(graph, positions, sizes, id) {
const groupSide = grouped ? GROUP_PADDING : 0;
const groupTop = grouped ? GROUP_PADDING + GROUP_HEADER : 0;
const side = ZONE_PADDING + groupSide;
const top = ZONE_PADDING + ZONE_HEADER + groupTop;
const top = ZONE_PADDING + zoneHeader(own?.zone) + groupTop;
const zones = zoneBoxes(graph, positions, sizes).filter(z => !z.nodes.includes(id)).map(toBox);
const groups = grouped ? groupFrames(own.nodes.filter(n => context(n) !== context(node)), positions, sizes).map(toBox) : [];
const zoneMates = rects(own?.nodes || []);
@@ -474,6 +493,62 @@ export function statusTone(status) {
return 'idle';
}
export const READABLE_ZOOM = 0.6;
const FIT_PAD = 48;
/**
* View ({x, y, z}) that shows the content bounds in the free part of the viewport ({left, top, width, height}).
* `readable`: never below READABLE_ZOOM; content that does not fit then starts at its top-left,
* so the cards can be read and the user scrolls to the rest.
*/
export function fitView(bounds, area, readable = false) {
const w = bounds.maxX - bounds.minX;
const h = bounds.maxY - bounds.minY;
const room = { w: area.width - FIT_PAD * 2, h: area.height - FIT_PAD * 2 };
const whole = Math.min(room.w / w, room.h / h);
const z = Math.min(1.1, Math.max(readable ? READABLE_ZOOM : MIN_ZOOM, whole));
const x = w * z <= room.w ? area.left + (area.width - w * z) / 2 : area.left + FIT_PAD;
const y = h * z <= room.h ? area.top + (area.height - h * z) / 2 : area.top + FIT_PAD;
return { z, x: x - bounds.minX * z, y: y - bounds.minY * z };
}
/** Resource cards per status tone ({ok, warn, bad, busy, idle}); unmanaged containers are not counted. */
export function statusSummary(nodes) {
const out = { ok: 0, warn: 0, bad: 0, busy: 0, idle: 0 };
for (const n of nodes || []) if (n.type !== 'container' && !isSource(n)) out[statusTone(n.status)]++;
return out;
}
/** "running:healthy" → "Running": the state word for the status text on a card. */
export function statusWord(status) {
return statusLabel(status).replace(/\s*\(.*$/, '');
}
/** 1007820800 → "1 GB", 17179869184 → "16 GB". */
export function formatMemory(bytes) {
if (!bytes) return null;
const gb = bytes / 1024 ** 3;
return `${gb < 10 ? Math.round(gb * 10) / 10 : Math.round(gb)} GB`;
}
/** Time since a "YYYY-MM-DD HH:MM:SS" (UTC) or ISO date, as "45m", "5h", "12d". */
export function formatUptime(since, now = Date.now()) {
const at = Date.parse(String(since || '').replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(since || '') ? '' : 'Z'));
if (!Number.isFinite(at)) return null;
const minutes = Math.max(0, Math.floor((now - at) / 60000));
if (minutes < 60) return `${minutes}m`;
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h`;
return `${Math.floor(minutes / 1440)}d`;
}
/**
* Ids of the server zones whose server is not reachable. The saved status of their resources is stale,
* so the canvas shows it as unknown. Zones with an icon (projects, connect view) are not servers.
*/
export function unreachableZones(graph) {
return new Set((graph?.zones || []).filter(z => !z.icon && z.is_reachable === false).map(z => z.id));
}
/** "running:healthy" → "Running (healthy)". */
export function statusLabel(status) {
const value = String(status ?? '').trim();
@@ -587,6 +662,7 @@ export const TONE_DOT = {
};
// Outline icons, drawn from local paths.
export const ICONS = {
chevron: ['M6 15l6-6 6 6'],
application: ['M3 6.5A2.5 2.5 0 0 1 5.5 4h13A2.5 2.5 0 0 1 21 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5z', 'M3 9h18', 'M9.5 13l-2 2 2 2', 'M14.5 13l2 2-2 2'],
service: ['M12 4 3 8.5l9 4.5 9-4.5z', 'M3 12.5l9 4.5 9-4.5', 'M3 16.5l9 4.5 9-4.5'],
database: ['M5 6c0-1.66 3.13-3 7-3s7 1.34 7 3-3.13 3-7 3-7-1.34-7-3z', 'M5 6v12c0 1.66 3.13 3 7 3s7-1.34 7-3V6', 'M5 12c0 1.66 3.13 3 7 3s7-1.34 7-3'],
+158 -32
View File
@@ -13,6 +13,13 @@ import {
filterGraph,
groupByUse,
filterOptions,
fitView,
formatMemory,
formatUptime,
SERVER_ZONE_HEADER,
statusSummary,
statusWord,
READABLE_ZOOM,
groupFrames,
layoutBlock,
estimateNodeHeight,
@@ -23,10 +30,11 @@ import {
statusTone,
zoneBoxes,
readView,
unreachableZones,
} from './environment-canvas.js';
import { freeSpot } from './canvas-core.js';
const node = (id, type, zone, extra = {}) => ({ id, type, zone, name: id, status: 'running:healthy', extra_zones: [], urls: [], children: [], volumes: [], ...extra });
const node = (id, type, zone, extra = {}) => ({ id, type, zone, name: id, status: 'running:healthy', extra_zones: [], urls: [], children: [], ...extra });
const graph = (nodes, extra = {}) => ({
zones: [{ id: 's1', name: 'One' }, { id: 's2', name: 'Two' }],
nodes,
@@ -101,7 +109,7 @@ test('auto-placed zones go below zones with stored cards', () => {
test('estimates taller cards for urls, chips, and children', () => {
const plain = estimateNodeHeight(node('a', 'service', 's1'));
const rich = estimateNodeHeight(node('b', 'service', 's1', { urls: ['https://x'], volumes: ['v'], children: [{ name: 'c' }, { name: 'd' }] }));
const rich = estimateNodeHeight(node('b', 'service', 's1', { urls: ['https://x'], extra_zones: ['s2'], children: [{ name: 'c' }, { name: 'd' }] }));
assert.ok(rich > plain);
const capped = estimateNodeHeight(node('c', 'service', 's1', { children: Array.from({ length: 20 }, (_, i) => ({ name: `c${i}` })) }));
const six = estimateNodeHeight(node('d', 'service', 's1', { children: Array.from({ length: 6 }, (_, i) => ({ name: `c${i}` })) }));
@@ -239,20 +247,6 @@ test('server view: one frame per project / environment, and the frames do not ov
assert.equal(positions.a.y, positions.b.y);
});
test('server view: a group keeps the arrangement saved on its environment canvas', () => {
const graph = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }],
nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'service', 'Shop / prod')],
links: [],
positions: { a: { x: 900, y: 500 }, b: { x: 600, y: 800 } },
};
const positions = layoutGraph(graph);
assert.equal(positions.a.x - positions.b.x, 300);
assert.equal(positions.b.y - positions.a.y, 300);
assert.ok(positions.c.y >= positions.b.y + estimateNodeHeight(graph.nodes[1]), 'a card without a saved position goes below');
});
const envNode = (id, type, zone, environment, extra = {}) => srv(id, type, `Shop / ${environment}`, { zone, project: { uuid: 'p' }, environment: { uuid: environment }, ...extra });
test('server view: cards link only to cards of the same environment', () => {
@@ -267,26 +261,158 @@ test('server view: cards link only to cards of the same environment', () => {
assert.equal(canConnect(g, 'container:1', 'a'), false);
});
test('server view: a moved group saved on its environment canvas keeps its arrangement', () => {
test('server view: positions saved on an environment canvas are ignored', () => {
const g = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }, { id: 'z2', name: 'db' }],
nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z1', 'prod'), envNode('c', 'service', 'z2', 'prod'), envNode('d', 'application', 'z1', 'dev')],
links: [{ id: 'l', source: 'a', target: 'b' }],
zones: [{ id: 'z1', name: 'web' }],
nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z1', 'prod')],
links: [],
positions: {},
};
const offsets = {};
const positions = layoutServerView(g, offsets);
// The user drags b 100px down; the canvas saves its group (a, b) in environment canvas coordinates.
positions.b = { x: positions.b.x, y: positions.b.y + 100 };
const saved = Object.fromEntries(['a', 'b'].map(id => [id, { x: positions[id].x - offsets[id].x, y: positions[id].y - offsets[id].y }]));
const again = layoutServerView({ ...g, positions: saved });
assert.deepEqual(again.a, positions.a);
assert.deepEqual(again.b, positions.b);
// On the environment canvas the saved cards do not cover the card of the same environment on the other server.
const environment = layoutGraph({ zones: g.zones, nodes: g.nodes.filter(n => n.environment.uuid === 'prod'), positions: saved });
const bottom = Math.max(...['a', 'b'].map(id => environment[id].y + estimateNodeHeight(g.nodes.find(n => n.id === id))));
assert.ok(environment.c.y > bottom);
const auto = layoutServerView(g);
assert.deepEqual(layoutServerView({ ...g, positions: { a: { x: 900, y: 700 } } }), auto);
});
test('server view: cards keep the arrangement saved on their environment canvas, new cards go below', () => {
const g = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }],
nodes: [
envNode('a', 'application', 'z1', 'prod', { saved: { x: 900, y: 700 } }),
envNode('b', 'database', 'z1', 'prod', { saved: { x: 600, y: 900 } }),
envNode('c', 'service', 'z1', 'prod'),
],
links: [],
};
const p = layoutServerView(g);
assert.equal(p.a.x - p.b.x, 300);
assert.equal(p.b.y - p.a.y, 200);
assert.equal(p.c.x, p.b.x);
assert.ok(p.c.y >= p.b.y + estimateNodeHeight(g.nodes[1]) + ROW_GAP);
});
test('server view: zones with server details get the taller header', () => {
const g = {
layout: 'server',
zones: [{ id: 'z1', name: 'web', details: { os: 'Ubuntu' } }],
nodes: [envNode('a', 'application', 'z1', 'prod')],
links: [],
positions: {},
};
const positions = layoutServerView(g);
const [box] = zoneBoxes(g, positions);
assert.equal(box.y, 0);
assert.ok(positions.a.y > SERVER_ZONE_HEADER);
});
test('server view: groups sit where their cards are saved on the environment canvas', () => {
const g = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }],
nodes: [
envNode('a', 'application', 'z1', 'prod', { saved: { x: 0, y: ZONE_HEADER + ZONE_PADDING } }),
envNode('b', 'application', 'z1', 'stage', { saved: { x: 600, y: ZONE_HEADER + ZONE_PADDING } }),
envNode('c', 'application', 'z1', 'dev'),
],
links: [],
positions: {},
};
const groups = {};
const positions = layoutServerView(g, groups);
// A card at the start of the environment canvas sits at the zone's top-left.
assert.equal(groups['prod:z1'].x, 0);
assert.equal(groups['prod:z1'].y, groups['prod:z1'].inner.y);
assert.equal(positions.b.x - positions.a.x, 600);
// The environment canvas position comes back from the server view position.
const { shift } = groups['prod:z1'];
assert.deepEqual({ x: positions.b.x - shift.x, y: positions.b.y - shift.y }, { x: 600, y: ZONE_HEADER + ZONE_PADDING });
// A group without saved cards goes below.
assert.ok(positions.c.y > positions.a.y + estimateNodeHeight(g.nodes[0]));
});
test('server view: a group saved left of the zone stays inside it', () => {
const g = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }],
nodes: [envNode('a', 'application', 'z1', 'prod', { saved: { x: -236, y: 251 } })],
links: [],
positions: {},
};
const groups = {};
layoutServerView(g, groups);
assert.equal(groups['prod:z1'].x, groups['prod:z1'].inner.x);
});
test('server view: saved groups of different environments that would overlap are moved apart', () => {
const g = {
layout: 'server',
zones: [{ id: 'z1', name: 'web' }],
nodes: [
envNode('a', 'application', 'z1', 'prod', { saved: { x: 0, y: 0 } }),
envNode('b', 'application', 'z1', 'stage', { saved: { x: 0, y: 0 } }),
],
links: [],
positions: {},
};
const groups = {};
layoutServerView(g, groups);
const [p, s] = [groups['prod:z1'], groups['stage:z1']];
assert.ok(Math.abs(p.x - s.x) >= CARD_WIDTH || Math.abs(p.y - s.y) >= estimateNodeHeight(g.nodes[0]), 'the groups do not cover each other');
});
test('status summary counts resources by tone, without unmanaged containers', () => {
const nodes = [
node('a', 'application', 'z', { status: 'running:healthy' }),
node('b', 'database', 'z', { status: 'exited:unhealthy' }),
node('c', 'service', 'z', { status: 'exited' }),
node('container:1', 'container', 'z', { status: 'exited' }),
];
assert.deepEqual(statusSummary(nodes), { ok: 1, warn: 0, bad: 2, busy: 0, idle: 0 });
assert.equal(statusWord('exited:unhealthy'), 'Exited');
});
test('server details: memory and uptime formats', () => {
assert.equal(formatMemory(1007820800), '0.9 GB');
assert.equal(formatMemory(17179869184), '16 GB');
assert.equal(formatMemory(null), null);
const now = Date.parse('2026-10-14T12:00:00Z');
assert.equal(formatUptime('2026-10-14 11:15:00', now), '45m');
assert.equal(formatUptime('2026-10-14 07:00:00', now), '5h');
assert.equal(formatUptime('2026-10-02T12:00:00+00:00', now), '12d');
assert.equal(formatUptime('nonsense', now), null);
});
test('unreachable server zones: only server zones whose server is not reachable', () => {
const zones = [
{ id: 'up', is_reachable: true },
{ id: 'down', is_reachable: false },
{ id: 'project', icon: 'projects' },
];
assert.deepEqual([...unreachableZones({ zones })], ['down']);
assert.equal(unreachableZones(null).size, 0);
});
test('fit: shows everything when it stays readable, centered', () => {
const v = fitView({ minX: 0, minY: 0, maxX: 400, maxY: 200 }, { left: 0, top: 0, width: 1000, height: 600 }, true);
assert.equal(v.z, 1.1);
assert.equal(v.x, (1000 - 440) / 2);
});
test('fit: the first fit of a large canvas keeps the cards readable and starts at the top-left', () => {
const bounds = { minX: 100, minY: 50, maxX: 3100, maxY: 4050 };
const area = { left: 0, top: 0, width: 1280, height: 520 };
assert.ok(fitView(bounds, area).z < READABLE_ZOOM, 'the fit button still shows everything');
const v = fitView(bounds, area, true);
assert.equal(v.z, READABLE_ZOOM);
assert.equal(v.x + bounds.minX * v.z, 48);
assert.equal(v.y + bounds.minY * v.z, 48);
});
test('fit: a tall, narrow canvas keeps its column centered', () => {
const v = fitView({ minX: 0, minY: 0, maxX: 300, maxY: 5000 }, { left: 0, top: 0, width: 1280, height: 520 }, true);
assert.equal(v.z, READABLE_ZOOM);
assert.equal(v.x, (1280 - 300 * READABLE_ZOOM) / 2);
assert.equal(v.y, 48);
});
test('variables view: variables stack on the left by name, resources sit to their right', () => {
@@ -168,7 +168,7 @@
'Metrics' => 'graph',
'Analytics' => 'analytics',
'Tags' => 'tags',
'Danger Zone' => 'shield-alert',
'Danger Zone' => 'danger',
];
// Discord-style groups for the settings sidebar
@@ -177,7 +177,9 @@
'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Files', 'Metrics', 'Analytics'],
'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags'],
// Always last, after a divider and without a group header.
'Danger Zone' => ['Danger Zone'],
];
$groupedMenuItems = collect($menuGroups)
->map(fn (array $labels) => collect($labels)
@@ -254,18 +256,39 @@
$settingsSearchItems = settingsSearchItems($groupedMenuItems, $applicationRouteParameters, $pageSections);
@endphp
@php
// The main sidebar shows this menu on wide screens (see components/navbar.blade.php).
request()->attributes->set('mainNavContext', [
'label' => 'Dashboard',
'backHref' => route('dashboard'),
'backTitle' => 'Back to the dashboard',
]);
@endphp
{{-- Rendered twice: in the page (below xl), and teleported into the main sidebar (xl and up). --}}
@foreach (['page', 'main-nav'] as $placement)
@if ($placement === 'main-nav')
@teleport('#main-nav-context')
<div class="nav-context-menu">
@else
<aside @class([
'application-settings-navigation min-w-0 xl:self-start',
'application-settings-navigation in-main-nav min-w-0 xl:self-start',
'is-flush' => $flush,
]) data-settings-search-items="{{ json_encode($settingsSearchItems) }}">
@endif
<nav aria-label="Configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@class([
'grid gap-0.5',
'grid-cols-2 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]' => $placement === 'page',
'grid-cols-1' => $placement === 'main-nav',
])>
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
@unless ($groupLabel === 'Danger Zone')
<button type="button" class="nav-section-toggle hidden xl:flex"
@click="toggle(@js($groupLabel))" :aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
@@ -275,33 +298,28 @@
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@endunless
<div @class(['contents', 'xl:block' => $groupLabel === 'Danger Zone']) @unless ($groupLabel === 'Danger Zone') :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'" @endunless>
@foreach ($groupItems as $menuItem)
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}">
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
<div wire:key="{{ $placement }}-application-settings-group-{{ str($menuItem['label'])->slug() }}">
<a wire:key="{{ $placement }}-application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
@if ($menuItem['label'] === 'Files')
x-data="{ loading: false }"
@click="if (!$event.ctrlKey && !$event.metaKey && !$event.shiftKey && !$event.altKey) loading = true"
x-on:livewire:navigated.window="loading = false"
@endif
href="{{ route($menuItem['route'], $applicationRouteParameters) }}"
>
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['label'] === 'Files')
<span x-show="loading" x-cloak><x-loading compact /></span>
<x-beta-badge class="ml-auto shrink-0" />
@endif
@if ($menuItem['badge'] ?? false)
<span class="shrink-0">
<livewire:project.application.server-status-badge :application="$application"
:key="'application-server-status-'.$application->uuid" />
:key="$placement.'-application-server-status-'.$application->uuid" />
</span>
@endif
</a>
@@ -324,4 +342,10 @@
</div>
@endforeach
</nav>
</aside>
@if ($placement === 'main-nav')
</div>
@endteleport
@else
</aside>
@endif
@endforeach
@@ -9,32 +9,24 @@
'application' => [
'back' => 'project.application.backup.index',
'general' => 'project.application.backup.show',
's3' => 'project.application.backup.s3',
'retention' => 'project.application.backup.retention',
'executions' => 'project.application.backup.executions',
'danger' => 'project.application.backup.danger',
],
'service' => [
'back' => 'project.service.volume-backups.index',
'general' => 'project.service.database.backup.show',
's3' => 'project.service.database.backup.s3',
'retention' => 'project.service.database.backup.retention',
'executions' => 'project.service.database.backup.executions',
'danger' => 'project.service.database.backup.danger',
],
'service-volume' => [
'back' => 'project.service.volume-backups.index',
'general' => 'project.service.volume-backups.show',
's3' => 'project.service.volume-backups.s3',
'retention' => 'project.service.volume-backups.retention',
'executions' => 'project.service.volume-backups.executions',
'danger' => 'project.service.volume-backups.danger',
],
default => [
'back' => 'project.database.configuration',
'general' => 'project.database.backup.execution',
's3' => 'project.database.backup.s3',
'retention' => 'project.database.backup.retention',
'executions' => 'project.database.backup.executions',
'danger' => 'project.database.backup.danger',
],
@@ -42,10 +34,8 @@
$items = [
['key' => 'general', 'label' => 'General', 'icon' => 'settings'],
['key' => 's3', 'label' => 'S3 storage', 'icon' => 'storages'],
['key' => 'retention', 'label' => 'Retention', 'icon' => 'unordered-list'],
['key' => 'executions', 'label' => 'Executions', 'icon' => 'browser-terminal'],
['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'],
['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'danger'],
];
$backLabel = $context === 'database' ? 'Back to database' : 'Back to backups';
$backParameters = match ($context) {
@@ -55,17 +45,20 @@
};
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
<x-main-nav-menu :back-href="route($routes['back'], $backParameters)" :back-label="$context === 'database' ? 'Database' : 'Backups'"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Backup' => collect($items)->map(fn (array $item): array => [...$item, 'route' => $routes[$item['key']]])], $parameters)) }}">
<nav aria-label="Backup settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Backup</div>
<a class="menu-item" {{ wireNavigate() }} href="{{ route($routes['back'], $backParameters) }}">
<a class="menu-item" data-page-only {{ wireNavigate() }} href="{{ route($routes['back'], $backParameters) }}">
<x-reicon name="logout" class="menu-item-icon rotate-180" />
<span class="menu-item-label">{{ $backLabel }}</span>
</a>
@foreach ($items as $item)
@if ($item['key'] === 'danger')
<div class="col-span-full my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endif
<a @class([
'menu-item',
'menu-item-active' => $section === $item['key'],
@@ -76,4 +69,4 @@
</a>
@endforeach
</nav>
</aside>
</x-main-nav-menu>
@@ -8,21 +8,15 @@
$routes = match ($context) {
'service-schedule' => [
'general' => true,
's3' => true,
'retention' => true,
'danger' => true,
],
'service' => [
'general' => 'project.service.database.backup.show',
's3' => 'project.service.database.backup.s3',
'retention' => 'project.service.database.backup.retention',
'executions' => 'project.service.database.backup.executions',
'danger' => 'project.service.database.backup.danger',
],
'service-volume' => [
'general' => 'project.service.volume-backups.show',
's3' => 'project.service.volume-backups.s3',
'retention' => 'project.service.volume-backups.retention',
'executions' => 'project.service.volume-backups.executions',
'danger' => 'project.service.volume-backups.danger',
],
@@ -30,8 +24,6 @@
$items = collect([
['key' => 'general', 'label' => 'General'],
['key' => 's3', 'label' => 'S3 storage'],
['key' => 'retention', 'label' => 'Retention'],
['key' => 'executions', 'label' => 'Executions'],
['key' => 'danger', 'label' => 'Danger Zone'],
])->filter(fn (array $item): bool => isset($routes[$item['key']]));
@@ -1,51 +1,49 @@
@props(['listUrl', 'canvasUrl', 'onCanvas' => false, 'views' => null])
@props(['listUrl' => null, 'canvasUrl' => null, 'onCanvas' => false, 'views' => null])
@php
$item = 'flex size-7.5 items-center justify-center rounded-md transition-colors';
$idle = 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg';
$idle = 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg dark:hover:bg-white/[0.06] dark:hover:text-fg';
$canvasItem = 'relative flex h-7.5 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium transition-colors';
$views ??= ['table' => ['Table view', 'unordered-list'], 'grid' => ['Grid view', 'grid']];
// One list view (no table / grid choice): the list page shows it selected.
// One list view (no table / grid choice): nothing to switch to on the list page.
$singleList = count($views) === 1;
@endphp
{{-- Table / grid / canvas switcher. On the list page, table and grid switch the
parent's Alpine viewMode; on the canvas page they link back to the list
parent's Alpine viewMode; the canvas page shows one "List view" link back
(?view= makes the list the remembered view again, see RemembersCanvasView).
Pages with one list view pass :views="['list' => [...]]". --}}
Pages with one list view pass :views="['list' => [...]]". Without a
canvasUrl it is only a table / grid switcher. --}}
@if ($onCanvas || ! $singleList || filled($canvasUrl))
<div {{ $attributes->merge(['class' => 'view-toggle']) }}>
@foreach ($views as $mode => [$label, $icon])
@if ($onCanvas)
<a href="{{ $listUrl }}?view={{ $mode }}" {{ wireNavigate() }} class="{{ $item }} {{ $idle }}"
aria-label="{{ $label }}" title="{{ $label }}">
<x-reicon :name="$icon" class="size-3.5" />
</a>
@elseif ($singleList)
<span class="{{ $item }} control-selected" aria-current="page" aria-label="{{ $label }}"
title="{{ $label }}">
<x-reicon :name="$icon" class="size-3.5" />
</span>
@else
<button type="button" x-on:click="setViewMode(@js($mode))" class="{{ $item }}"
:class="viewMode === @js($mode) ? 'control-selected' : @js($idle)"
aria-label="{{ $label }}" title="{{ $label }}">
<x-reicon :name="$icon" class="size-3.5" />
</button>
@endif
@endforeach
@if ($onCanvas)
<span class="{{ $canvasItem }} control-selected" aria-current="page" title="Canvas view (beta)">
<x-reicon name="deploy-canvas" class="size-3.5" />
Canvas
</span>
@else
{{-- A label and a purple "Try it" badge, so people notice the canvas view. --}}
<a href="{{ $canvasUrl }}" {{ wireNavigate() }}
class="{{ $canvasItem }} {{ $idle }}" title="Canvas view (beta): see everything and how it connects">
<x-reicon name="deploy-canvas" class="size-3.5" />
Canvas
<span
class="ml-0.5 inline-flex items-center rounded-full bg-coollabs px-1.5 py-px text-[10px] leading-4 font-semibold text-white">
Try it
</span>
{{-- On the canvas only the way back: one labelled link to the list (its first view). --}}
@php [$label, $icon] = reset($views); @endphp
<a href="{{ $listUrl }}?view={{ array_key_first($views) }}" {{ wireNavigate() }} class="{{ $canvasItem }} {{ $idle }}"
title="List view">
<x-reicon :name="$icon" class="size-3.5" />
List view
</a>
@else
{{-- Only the views you can switch to, with their names; the current one is not shown. --}}
@unless ($singleList)
@foreach ($views as $mode => [$label, $icon])
<button type="button" x-on:click="setViewMode(@js($mode))" x-show="viewMode !== @js($mode)" x-cloak
class="{{ $canvasItem }} {{ $idle }}" title="{{ $label }}">
<x-reicon :name="$icon" class="size-3.5" />
{{ $label }}
</button>
@endforeach
@endunless
@if (filled($canvasUrl))
{{-- A label and a purple "Try it" badge, so people notice the canvas view. --}}
<a href="{{ $canvasUrl }}" {{ wireNavigate() }}
class="{{ $canvasItem }} {{ $idle }}" title="Canvas view (beta): see everything and how it connects">
<x-reicon name="deploy-canvas" class="size-3.5" />
Canvas
<span
class="ml-0.5 inline-flex items-center rounded-full bg-coollabs px-1.5 py-px text-[10px] leading-4 font-semibold text-white">
Try it
</span>
</a>
@endif
@endif
</div>
@endif
@@ -23,7 +23,7 @@
['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'icon' => 'server-update'],
['label' => 'Metrics', 'route' => 'project.database.metrics', 'icon' => 'graph'],
['label' => 'Tags', 'route' => 'project.database.tags', 'icon' => 'tags'],
['label' => 'Danger Zone', 'route' => 'project.database.danger', 'icon' => 'shield-alert'],
['label' => 'Danger Zone', 'route' => 'project.database.danger', 'icon' => 'danger'],
])->filter(fn (array $item): bool => $item['visible'] ?? true)
->map(fn (array $item): array => [
...$item,
@@ -37,7 +37,9 @@
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Metrics'],
'Deploy' => ['Servers'],
'Automation' => ['Webhooks', 'Backups', 'Import Backup'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Tags', 'Danger Zone'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Tags'],
// Always last, after a divider and without a group header.
'Danger Zone' => ['Danger Zone'],
];
$groupedItems = collect($menuGroups)
@@ -64,15 +66,36 @@
: [];
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
@php
// The main sidebar shows this menu on wide screens (see components/navbar.blade.php).
request()->attributes->set('mainNavContext', [
'label' => 'Dashboard',
'backHref' => route('dashboard'),
'backTitle' => 'Back to the dashboard',
]);
@endphp
{{-- Rendered twice: in the page (below xl), and teleported into the main sidebar (xl and up). --}}
@foreach (['page', 'main-nav'] as $placement)
@if ($placement === 'main-nav')
@teleport('#main-nav-context')
<div class="nav-context-menu">
@else
<aside class="application-settings-navigation in-main-nav min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $databaseRouteParameters, ['project.database.configuration' => $pageSections])) }}">
@endif
<nav aria-label="Database settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.database' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@class([
'grid gap-0.5',
'grid-cols-2 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]' => $placement === 'page',
'grid-cols-1' => $placement === 'main-nav',
])>
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
@unless ($groupLabel === 'Danger Zone')
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
@@ -82,20 +105,15 @@
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@endunless
<div @class(['contents', 'xl:block' => $groupLabel === 'Danger Zone']) @unless ($groupLabel === 'Danger Zone') :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'" @endunless>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
@if ($menuItem['label'] === 'Files')
x-data="{ loading: false }"
@click="if (!$event.ctrlKey && !$event.metaKey && !$event.shiftKey && !$event.altKey) loading = true"
x-on:livewire:navigated.window="loading = false"
@endif
href="{{ route($menuItem['route'], $databaseRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['label'] === 'Files')
<span x-show="loading" x-cloak><x-loading compact /></span>
<x-beta-badge class="ml-auto shrink-0" />
@endif
</a>
@@ -121,4 +139,10 @@
</div>
@endforeach
</nav>
@if ($placement === 'main-nav')
</div>
@endteleport
@else
</aside>
@endif
@endforeach
@@ -0,0 +1,19 @@
@props(['backHref', 'backLabel', 'backTitle' => null])
@php
// The main sidebar replaces its menu with this one on wide screens (see components/navbar.blade.php).
request()->attributes->set('mainNavContext', [
'label' => $backLabel,
'backHref' => $backHref,
'backTitle' => $backTitle ?? 'Back to '.$backLabel,
]);
@endphp
{{-- A page menu, rendered twice: in the page (below xl, CSS hides it on xl), and teleported into the
main sidebar (xl and up). The slot must not contain Livewire components (they would render twice). --}}
<aside {{ $attributes->class('application-settings-navigation in-main-nav min-w-0 xl:self-start') }}>
{{ $slot }}
</aside>
@teleport('#main-nav-context')
<div class="nav-context-menu">
{{ $slot }}
</div>
@endteleport
+194 -162
View File
@@ -1,6 +1,6 @@
@props(['deploymentsIndicator' => false])
@props(['desktop' => false])
<nav class="sidebar-nav flex min-h-0 flex-col flex-1 overflow-y-auto bg-white border-r border-neutral-200 dark:border-white/[0.06] dark:bg-panel pt-2"
<nav class="sidebar-nav flex min-h-0 flex-col flex-1 bg-white border-r border-neutral-200 dark:border-white/[0.06] dark:bg-panel pt-2"
:class="collapsed ? 'px-2 lg:px-3 sidebar-collapsed' : 'px-2 lg:px-3'"
@mouseover="
if (!collapsed) return;
@@ -17,6 +17,12 @@
@mouseleave="tooltip.show = false"
x-data="{
tooltip: { text: '', x: 0, y: 0, show: false },
// Label of the open submenu ('' = main menu). It starts on the submenu of the current page
// ([data-active-submenu], read in init). Back opens the main menu.
submenu: '',
// Direction of the last main menu/submenu swap ('forward' or 'back'); it selects the enter animation.
// A swap that loads a new page stores it in sessionStorage, so the new navbar plays it once.
navSwap: sessionStorage.getItem('navSwap') || '',
// macOS/iOS use ⌘; Windows/Linux use Ctrl+
modKeyLabel: (() => {
const platform = navigator.userAgentData?.platform || navigator.platform || '';
@@ -24,6 +30,9 @@
return /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X|Macintosh/i.test(ua) ? '⌘' : 'Ctrl+';
})(),
init() {
// Cleared after this tick: the mobile and desktop navbars both read it on init.
setTimeout(() => sessionStorage.removeItem('navSwap'));
this.submenu = this.$root.querySelector('[data-active-submenu]')?.dataset.activeSubmenu || '';
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
const userSettings = localStorage.getItem('theme');
if (userSettings !== 'system') { return; }
@@ -73,168 +82,194 @@
</div>
@endif
<ul role="list" class="-mx-1 flex flex-1 flex-col gap-y-0.5 px-1 pb-2">
<ul role="list" class="sidebar-menu -mx-1 flex min-h-0 flex-1 flex-col gap-y-0.5 overflow-y-auto px-1 pb-2">
@if (isSubscribed() || !isCloud())
{{-- Workspace --}}
<li class="nav-section" :class="collapsed && 'lg:hidden'">Workspace</li>
<li>
<a title="Dashboard" href="/" {{ wireNavigate() }}
class="{{ request()->is('/') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'">
<x-reicon name="dashboard" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Dashboard</span>
</a>
</li>
<li>
<a title="Projects" {{ wireNavigate() }}
class="{{ request()->is('project/*') || request()->is('projects') ? 'menu-item menu-item-active' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="/projects">
<x-reicon name="projects" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Projects</span>
</a>
</li>
<li>
<a title="Analytics" {{ wireNavigate() }}
class="{{ request()->is('analytics') ? 'menu-item menu-item-active' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('analytics') }}">
<x-reicon name="analytics" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Analytics</span>
</a>
</li>
<li>
<a title="Metrics" {{ wireNavigate() }}
class="{{ request()->is('metrics') ? 'menu-item menu-item-active' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('metrics') }}">
<x-reicon name="graph" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Metrics</span>
</a>
</li>
@can('canAccessTerminal')
<li>
<a title="Terminal"
class="{{ request()->is('terminal*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('terminal') }}">
<x-reicon name="browser-terminal" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Terminal</span>
</a>
</li>
@endcan
{{-- Infrastructure --}}
<li class="nav-section mt-3" aria-hidden="true"
:class="collapsed && 'lg:mx-2.5 lg:my-2 lg:h-0 lg:overflow-hidden lg:border-t lg:border-neutral-200 lg:p-0 lg:text-transparent dark:lg:border-white/10'">
Infrastructure</li>
<li>
<a title="Servers" {{ wireNavigate() }}
class="{{ request()->is('server/*') || request()->is('servers') ? 'menu-item menu-item-active' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="/servers">
<x-reicon name="servers" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Servers</span>
</a>
</li>
<li>
<a title="Sources" {{ wireNavigate() }}
class="{{ request()->is('source*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('source.all') }}">
<x-reicon name="sources" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Sources</span>
</a>
</li>
<li>
<a title="Destinations" {{ wireNavigate() }}
class="{{ request()->is('destination*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('destination.index') }}">
<x-reicon name="destinations" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Destinations</span>
</a>
</li>
@if (auth()->user()?->isAdmin())
<li>
<a title="Registries" {{ wireNavigate() }}
class="{{ request()->is('registries*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('registries.index') }}">
<x-reicon name="layers" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Registries</span>
</a>
</li>
@endif
<li>
<a title="S3 Storage" {{ wireNavigate() }}
class="{{ request()->is('storages*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('storage.index') }}">
<x-reicon name="storages" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">S3 Storage</span>
</a>
</li>
<li>
<a title="Shared variables" {{ wireNavigate() }}
class="{{ request()->is('shared-variables*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('shared-variables.index') }}">
<x-reicon name="variables" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Shared Variables</span>
</a>
</li>
@php
// An entry with children opens a submenu in place of the main menu (back button + its pages).
$navSections = collect([
'Workspace' => [
// The dashboard lists projects and links to all of them, so project pages count as Dashboard.
['label' => 'Dashboard', 'icon' => 'dashboard', 'href' => '/', 'active' => request()->is('/', 'dashboard/*', 'projects', 'projects/*', 'project/*')],
['label' => 'Monitoring', 'icon' => 'graph', 'children' => [
['label' => 'Analytics', 'icon' => 'analytics', 'href' => route('analytics'), 'active' => request()->is('analytics')],
['label' => 'Metrics', 'icon' => 'graph', 'href' => route('metrics'), 'active' => request()->is('metrics')],
]],
['label' => 'Terminal', 'icon' => 'browser-terminal', 'href' => route('terminal'), 'active' => request()->is('terminal*'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
],
'Infrastructure' => [
['label' => 'Servers', 'icon' => 'servers', 'href' => '/servers', 'active' => request()->is('server/*', 'servers')],
['label' => 'Integrations', 'icon' => 'link', 'children' => [
['label' => 'Sources', 'icon' => 'sources', 'href' => route('source.all'), 'active' => request()->is('sources', 'source/*')],
['label' => 'Destinations', 'icon' => 'destinations', 'href' => route('destination.index'), 'active' => request()->is('destinations', 'destination/*')],
['label' => 'Registries', 'icon' => 'layers', 'href' => route('registries.index'), 'active' => request()->is('registries'), 'visible' => auth()->user()?->isAdmin()],
['label' => 'S3 Storage', 'icon' => 'storages', 'href' => route('storage.index'), 'active' => request()->is('storages', 'storages/*')],
]],
['label' => 'Shared Variables', 'icon' => 'variables', 'children' => [
['label' => 'Overview', 'icon' => 'dashboard', 'href' => route('shared-variables.index'), 'active' => request()->is('shared-variables')],
['label' => 'Team', 'icon' => 'teams', 'href' => route('shared-variables.team.index'), 'active' => request()->is('shared-variables/team*')],
['label' => 'Projects', 'icon' => 'projects', 'href' => route('shared-variables.project.index'), 'active' => request()->is('shared-variables/projects', 'shared-variables/project/*')],
['label' => 'Environments', 'icon' => 'layers', 'href' => route('shared-variables.environment.index'), 'active' => request()->is('shared-variables/environments*')],
['label' => 'Servers', 'icon' => 'servers', 'href' => route('shared-variables.server.index'), 'active' => request()->is('shared-variables/servers', 'shared-variables/server/*')],
]],
],
'Manage' => [
['label' => 'Team', 'icon' => 'teams', 'children' => [
['label' => 'General', 'icon' => 'settings', 'href' => route('team.index'), 'active' => request()->is('team')],
['label' => 'Members', 'icon' => 'teams', 'href' => route('team.member.index'), 'active' => request()->is('team/members*')],
['label' => 'Audit log', 'icon' => 'time-back', 'href' => route('team.audit-log'), 'active' => request()->is('team/audit-log'), 'visible' => auth()->user()?->isAdminOfTeam(currentTeam()->id)],
['label' => 'Admin View', 'icon' => 'admin', 'href' => route('team.admin-view'), 'active' => request()->is('team/admin'), 'visible' => isInstanceAdmin()],
['label' => 'Danger Zone', 'icon' => 'danger', 'href' => route('team.danger-zone'), 'active' => request()->is('team/danger'), 'visible' => auth()->user()?->can('delete', currentTeam()), 'sectionStart' => true],
]],
['label' => 'Notifications', 'icon' => 'notifications', 'children' => [
['label' => 'Email', 'icon' => 'mail', 'href' => route('notifications.email'), 'active' => request()->is('notifications/email')],
['label' => 'Discord', 'brandIcon' => 'discord', 'href' => route('notifications.discord'), 'active' => request()->is('notifications/discord')],
['label' => 'Telegram', 'brandIcon' => 'telegram', 'href' => route('notifications.telegram'), 'active' => request()->is('notifications/telegram')],
['label' => 'Slack', 'brandIcon' => 'slack', 'href' => route('notifications.slack'), 'active' => request()->is('notifications/slack')],
['label' => 'Pushover', 'brandIcon' => 'pushover', 'href' => route('notifications.pushover'), 'active' => request()->is('notifications/pushover')],
['label' => 'Webhook', 'icon' => 'destinations', 'href' => route('notifications.webhook'), 'active' => request()->is('notifications/webhook')],
]],
['label' => 'Keys & Tokens', 'icon' => 'keys', 'children' => [
['label' => 'Private Keys', 'icon' => 'keys', 'href' => route('security.private-key.index'), 'active' => request()->is('security/private-key*')],
['label' => 'Cloud Tokens', 'icon' => 'cloud', 'href' => route('security.cloud-tokens'), 'active' => request()->is('security/cloud-tokens*'), 'visible' => auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class)],
['label' => 'Integration Tokens', 'icon' => 'network', 'href' => route('security.integration-tokens'), 'active' => request()->is('security/integration-tokens'), 'visible' => auth()->user()?->can('viewAny', App\Models\IntegrationToken::class)],
['label' => 'Cloud-Init Scripts', 'icon' => 'file-content', 'href' => route('security.cloud-init-scripts'), 'active' => request()->is('security/cloud-init-scripts*'), 'visible' => auth()->user()?->can('viewAny', App\Models\CloudInitScript::class)],
['label' => 'API Tokens', 'icon' => 'code', 'href' => route('security.api-tokens'), 'active' => request()->is('security/api-tokens')],
]],
['label' => 'Subscription', 'icon' => 'subscription', 'href' => isCloud() ? route('subscription.show') : '', 'active' => request()->is('subscription*'), 'visible' => isCloud() && auth()->user()->isAdmin()],
['label' => 'Tags', 'icon' => 'tags', 'href' => route('tags.show'), 'active' => request()->is('tags*')],
['label' => 'Settings', 'icon' => 'settings', 'visible' => isInstanceAdmin(), 'children' => [
['label' => 'General', 'icon' => 'settings', 'href' => route('settings.index'), 'active' => request()->is('settings')],
['label' => 'Advanced', 'icon' => 'grid', 'href' => route('settings.advanced'), 'active' => request()->is('settings/advanced')],
['label' => 'Updates', 'icon' => 'refresh3', 'href' => route('settings.updates'), 'active' => request()->is('settings/updates')],
['label' => 'Backup', 'icon' => 'database', 'href' => route('settings.backup'), 'active' => request()->is('settings/backup'), 'sectionStart' => true],
['label' => 'Email', 'icon' => 'mail', 'href' => route('settings.email'), 'active' => request()->is('settings/email')],
['label' => 'Authentication', 'icon' => 'keys', 'href' => route('settings.oauth'), 'active' => request()->is('settings/oauth*')],
]],
],
])->map(fn (array $items) => collect($items)
->filter(fn (array $item): bool => (bool) ($item['visible'] ?? true))
->map(function (array $item): array {
if (! isset($item['children'])) {
return $item;
}
$children = array_values(array_filter($item['children'], fn (array $child): bool => (bool) ($child['visible'] ?? true)));
{{-- Manage --}}
<li class="nav-section mt-3" aria-hidden="true"
:class="collapsed && 'lg:mx-2.5 lg:my-2 lg:h-0 lg:overflow-hidden lg:border-t lg:border-neutral-200 lg:p-0 lg:text-transparent dark:lg:border-white/10'">
Manage</li>
<li>
<a title="Team" {{ wireNavigate() }}
class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('team.index') }}">
<x-reicon name="teams" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Team</span>
</a>
</li>
<li>
<a title="Notifications" {{ wireNavigate() }}
class="{{ request()->is('notifications*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('notifications.email') }}">
<x-reicon name="notifications" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Notifications</span>
</a>
</li>
<li>
<a title="Keys & Tokens" {{ wireNavigate() }}
class="{{ request()->is('security*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('security.private-key.index') }}">
<x-reicon name="keys" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Keys & Tokens</span>
</a>
</li>
@if (isCloud() && auth()->user()->isAdmin())
<li>
<a title="Subscription" {{ wireNavigate() }}
class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('subscription.show') }}">
<x-reicon name="subscription" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Subscription</span>
</a>
return [...$item, 'children' => $children, 'href' => $children[0]['href'], 'active' => collect($children)->contains('active', true)];
})
->all());
$activeSubmenu = $navSections->flatten(1)->first(fn (array $item): bool => isset($item['children']) && $item['active']);
// Set by a resource menu (e.g. components/application/configuration-sidebar.blade.php) that
// teleports itself into #main-nav-context; only the desktop sidebar has the slot.
$navContext = $desktop ? request()->attributes->get('mainNavContext') : null;
@endphp
{{-- Wide screens on a resource page: the resource menu replaces the main menu; Back opens the dashboard. --}}
@if ($navContext)
<li class="hidden xl:contents">
<ul role="list" class="flex flex-col gap-y-0.5">
<li class="mb-1">
<a href="{{ $navContext['backHref'] }}" {{ wireNavigate() }} title="{{ $navContext['backTitle'] }}" aria-label="{{ $navContext['backTitle'] }}"
class="menu-item menu-back bg-transparent! hover:bg-neutral-100! dark:hover:bg-white/[0.06]!" :class="collapsed && 'lg:justify-center lg:px-0'">
<span class="menu-back-icon flex shrink-0 items-center justify-center">
<x-reicon name="chevron-down" class="size-3 rotate-90 text-neutral-500 dark:text-fg-dim" />
</span>
<span class="flex-1 truncate pr-6 text-center font-medium text-black dark:text-fg"
:class="collapsed && 'lg:hidden'">{{ $navContext['label'] }}</span>
</a>
</li>
<li id="main-nav-context" class="nav-context min-w-0"></li>
</ul>
</li>
@endif
<li>
<a title="Tags" {{ wireNavigate() }}
class="{{ request()->is('tags*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('tags.show') }}">
<x-reicon name="tags" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Tags</span>
</a>
</li>
@if (isInstanceAdmin())
<li>
<a title="Settings" {{ wireNavigate() }}
class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('settings.index') }}">
<x-reicon name="settings" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Settings</span>
</a>
{{-- Submenus: a parent entry opens its submenu in place, and the user picks a page (the mobile
drawer stays open until then). Back shows the main menu without leaving the page. --}}
@php
$renderedSubmenus = $navSections->flatten(1)->filter(fn (array $item): bool => isset($item['children']));
@endphp
@foreach ($renderedSubmenus as $submenuItem)
<li x-show="submenu === @js($submenuItem['label'])"
@if ($submenuItem['active']) data-active-submenu="{{ $submenuItem['label'] }}" @else x-cloak @endif
@class(['contents', 'xl:hidden' => $navContext])>
<ul role="list" class="flex flex-col gap-y-0.5" :class="navSwap === 'forward' && 'nav-swap-forward'">
<li class="mb-1">
<button type="button" @click="submenu = ''; navSwap = 'back'" title="Back to the main menu" aria-label="Back to the main menu"
class="menu-item menu-back bg-transparent! hover:bg-neutral-100! dark:hover:bg-white/[0.06]!" :class="collapsed && 'lg:justify-center lg:px-0'">
{{-- As wide as a menu icon (with its tile), so the chevron lines up with the icons below. --}}
<span class="menu-back-icon flex shrink-0 items-center justify-center">
<x-reicon name="chevron-down" class="size-3 rotate-90 text-neutral-500 dark:text-fg-dim" />
</span>
<span class="flex-1 truncate pr-6 text-center font-medium text-black dark:text-fg"
:class="collapsed && 'lg:hidden'">{{ $submenuItem['label'] }}</span>
</button>
</li>
@foreach ($submenuItem['children'] as $child)
@if ($child['sectionStart'] ?? false)
<li class="my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></li>
@endif
<li>
<a title="{{ $child['label'] }}" href="{{ $child['href'] }}" {{ wireNavigate() }}
@if ($child['active']) aria-current="page" @endif
@class(['menu-item', 'menu-item-active' => $child['active']])
:class="collapsed && 'lg:justify-center lg:px-0'">
@isset($child['brandIcon'])
{{-- The mask is on an inner span, so the outer span keeps the icon tile. --}}
<span class="menu-item-icon">
<span class="block size-full bg-current"
style="mask: url('{{ asset('svgs/'.$child['brandIcon'].'.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/'.$child['brandIcon'].'.svg') }}') center / contain no-repeat;"></span>
</span>
@else
<x-reicon :name="$child['icon']" class="menu-item-icon" />
@endisset
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">{{ $child['label'] }}</span>
</a>
</li>
@if ($child['active'] && $desktop)
{{-- A page can teleport its own sub-items here (e.g. the OAuth providers). --}}
<li id="main-nav-active-children" class="empty:hidden ml-5 border-l border-neutral-200 pl-2 dark:border-white/[0.08]" :class="collapsed && 'lg:hidden'"></li>
@endif
@endforeach
</ul>
</li>
@endif
@endforeach
<li x-show="!submenu" @if ($activeSubmenu) x-cloak @endif @class(['contents', 'xl:hidden' => $navContext])>
<ul role="list" class="flex flex-col gap-y-0.5" :class="navSwap === 'back' && 'nav-swap-back'">
@foreach ($navSections as $sectionLabel => $sectionItems)
@if ($loop->first)
<li class="nav-section" :class="collapsed && 'lg:hidden'">{{ $sectionLabel }}</li>
@else
<li class="nav-section mt-3" aria-hidden="true"
:class="collapsed && 'lg:mx-2.5 lg:my-2 lg:h-0 lg:overflow-hidden lg:border-t lg:border-neutral-200 lg:p-0 lg:text-transparent dark:lg:border-white/10'">
{{ $sectionLabel }}</li>
@endif
@foreach ($sectionItems as $item)
<li>
{{-- A parent entry is a button, not a link: wire:navigate would start a page load on mouse down. --}}
@isset($item['children'])
<button type="button" title="{{ $item['label'] }}" @click="submenu = @js($item['label']); navSwap = 'forward'"
@class(['menu-item w-full', 'menu-item-active' => $item['active']])
:class="collapsed && 'lg:justify-center lg:px-0'">
<x-reicon :name="$item['icon']" class="menu-item-icon" />
<span class="menu-item-label flex-1 text-left" :class="collapsed && 'lg:hidden'">{{ $item['label'] }}</span>
<x-reicon name="chevron-down" class="size-3 -rotate-90 text-fg-faint" ::class="collapsed && 'lg:hidden'" />
</button>
@else
<a title="{{ $item['label'] }}" href="{{ $item['href'] }}"
@if ($item['navigate'] ?? true) {{ wireNavigate() }} @endif
@class(['menu-item', 'menu-item-active' => $item['active']])
:class="collapsed && 'lg:justify-center lg:px-0'">
<x-reicon :name="$item['icon']" class="menu-item-icon" />
<span class="menu-item-label flex-1" :class="collapsed && 'lg:hidden'">{{ $item['label'] }}</span>
</a>
@endisset
</li>
@endforeach
@endforeach
</ul>
</li>
<li class="flex-1" aria-hidden="true"></li>
@endif
@if (auth()->id() === 0 && (isCloud() || isDev()))
<li>
{{-- Part of the main menu: hidden while a submenu or a resource menu shows. --}}
<li x-show="!submenu" @if ($activeSubmenu ?? null) x-cloak @endif @class(['xl:hidden' => $navContext ?? null])>
<a title="Admin" {{ wireNavigate() }}
class="{{ request()->is('admin') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('admin.index') }}">
@@ -264,12 +299,9 @@
@endif
@endif
</ul>
{{-- Sidebar footer (desktop only; mobile uses a temporary slide-over). It scrolls with the
menu so no item hides behind it on short or zoomed-in screens. --}}
<div class="sidebar-footer mt-auto -mx-2 hidden flex-col gap-1 bg-white px-2 py-2 dark:bg-panel lg:-mx-3 lg:flex lg:px-3">
@if ($deploymentsIndicator)
<livewire:deployments-indicator variant="sidebar" />
@endif
{{-- Sidebar footer (desktop only; mobile uses a temporary slide-over). It stays visible;
only the menu above it scrolls on short or zoomed-in screens. --}}
<div class="sidebar-footer shrink-0 -mx-2 hidden flex-col gap-1 bg-white px-2 py-2 dark:bg-panel lg:-mx-3 lg:flex lg:px-3">
<div class="flex items-center gap-1" :class="collapsed ? 'flex-col justify-center' : 'justify-between'">
<x-top-user-menu sidebar />
<button type="button" @click="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar"
@@ -9,35 +9,15 @@
];
@endphp
<section class="application-settings-workspace w-full max-w-none">
{{-- These pages are in the Notifications submenu of the main sidebar (components/navbar.blade.php). --}}
<section class="w-full max-w-none">
<header class="settings-mobile-header xl:hidden">
<h1 class="settings-mobile-title">Notifications</h1>
<p class="settings-mobile-description">Configure how your team receives deployment and system alerts.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Notifications' => $notificationMenuItems], [])) }}">
<nav aria-label="Notification settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Notifications</div>
@foreach ($notificationMenuItems as $menuItem)
<a wire:key="notification-settings-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => request()->routeIs($menuItem['route'])])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
@if (isset($menuItem['brandIcon']))
<span class="menu-item-icon bg-current"
style="mask: url('{{ asset('svgs/' . $menuItem['brandIcon'] . '.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/' . $menuItem['brandIcon'] . '.svg') }}') center / contain no-repeat;"></span>
@else
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
@endif
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
<div class="min-w-0">
{{ $slot }}
</div>
{{-- Feeds the page search with the Notifications pages. --}}
<div hidden data-settings-search-items="{{ json_encode(settingsSearchItems(['Notifications' => $notificationMenuItems], [])) }}"></div>
<div class="min-w-0">
{{ $slot }}
</div>
</section>
+4 -2
View File
@@ -86,10 +86,12 @@
'network' => '<g transform="scale(0.09375)"><path d="M240,120a8,8,0,0,1-8,8H200v32h8a16,16,0,0,1,16,16v32a16,16,0,0,1-16,16H176a16,16,0,0,1-16-16V176a16,16,0,0,1,16-16h8V128H72v32h8a16,16,0,0,1,16,16v32a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V176a16,16,0,0,1,16-16h8V128H24a8,8,0,0,1,0-16h96V88h-8A16,16,0,0,1,96,72V40a16,16,0,0,1,16-16h32a16,16,0,0,1,16,16V72a16,16,0,0,1-16,16h-8v24h96A8,8,0,0,1,240,120Z" fill="currentColor"/></g>',
];
$svg = $icons[$name] ?? '';
// 'danger' is the shield-alert glyph in the error color, for Danger Zone menu items.
$isDanger = $name === 'danger';
$svg = $icons[$isDanger ? 'shield-alert' : $name] ?? '';
@endphp
<svg {{ $attributes->merge(['class' => 'size-4']) }} viewBox="0 0 24 24" fill="none"
<svg {{ $attributes->class(['size-4', 'text-error' => $isDanger]) }} viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
{!! $svg !!}
</svg>
@@ -0,0 +1,8 @@
{{-- The "View all" link of x-section-heading (uses its $href, $actionLabel, and $icon). --}}
<a href="{{ $href }}" {{ wireNavigate() }}
class="group inline-flex shrink-0 items-center gap-0.5 text-[11px] text-neutral-500 hover:text-black hover:underline dark:text-fg-faint dark:hover:text-fg">
{{ $actionLabel }}
@if ($icon)
<x-reicon :name="$icon" class="size-3 transition-transform duration-150 ease-out group-hover:translate-x-0.5" />
@endif
</a>
@@ -3,29 +3,38 @@
'subtitle' => null,
'href' => null,
'actionLabel' => 'View all',
'icon' => 'arrow-right',
'icon' => null,
])
{{--
Shared section heading: a 14px title, an optional 11px muted subtitle, and an
optional right-aligned action. Pass `href` for the default "View all"
button, or an `<x-slot:actions>` for a custom control.
optional action. Pass `href` for a small "View all" link and an
`<x-slot:actions>` for a right-aligned control; with both, the link sits
before the control, otherwise next to the title. `<x-slot:titleAction>`
puts a small control (such as a + button) right after the title.
--}}
<div {{ $attributes->merge(['class' => 'mb-3 min-w-0']) }}>
<div class="flex items-center justify-between gap-4">
<h2 class="min-w-0 truncate text-[14px]! leading-5! font-semibold! text-black dark:text-fg">
{{ $title }}
</h2>
<div class="flex min-w-0 items-baseline gap-2">
<div class="flex min-w-0 items-center gap-1">
<h2 class="min-w-0 truncate text-[14px]! leading-5! font-semibold! text-black dark:text-fg">
{{ $title }}
</h2>
@isset($titleAction)
{{ $titleAction }}
@endisset
</div>
@if (filled($href) && ! isset($actions))
@include('components.section-heading-link')
@endif
</div>
@isset($actions)
<div class="shrink-0">{{ $actions }}</div>
@elseif (filled($href))
<a href="{{ $href }}" {{ wireNavigate() }}
class="button group">
{{ $actionLabel }}
@if ($icon)
<x-reicon :name="$icon" class="size-3 opacity-70 transition-transform duration-150 ease-out group-hover:translate-x-0.5" />
<div class="flex shrink-0 items-center gap-3">
@if (filled($href))
@include('components.section-heading-link')
@endif
</a>
{{ $actions }}
</div>
@endisset
</div>
@if (filled($subtitle))
@@ -33,35 +33,20 @@
])->filter();
@endphp
<section class="application-settings-workspace w-full max-w-none">
{{-- These pages are in the Keys & Tokens submenu of the main sidebar (components/navbar.blade.php). --}}
<section class="w-full max-w-none">
<header class="settings-mobile-header xl:hidden">
<h1 class="settings-mobile-title">Keys & Tokens</h1>
<p class="settings-mobile-description">Manage SSH keys, cloud credentials, and API access tokens.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Keys & Tokens' => $securityMenuItems], [])) }}">
<nav aria-label="Keys and tokens"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Keys & Tokens</div>
@foreach ($securityMenuItems as $menuItem)
<a wire:key="security-settings-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
<div class="min-w-0">
@isset($actions)
<div class="mb-3 flex min-h-8 flex-wrap items-center justify-end gap-2">
{{ $actions }}
</div>
@endisset
{{ $slot }}
</div>
{{-- Feeds the page search with the Keys & Tokens pages. --}}
<div hidden data-settings-search-items="{{ json_encode(settingsSearchItems(['Keys & Tokens' => $securityMenuItems], [])) }}"></div>
<div class="min-w-0">
@isset($actions)
<div class="mb-3 flex min-h-8 flex-wrap items-center justify-end gap-2">
{{ $actions }}
</div>
@endisset
{{ $slot }}
</div>
</section>
@@ -203,7 +203,7 @@
'label' => 'Danger',
'route' => 'server.delete',
'active' => $activeMenu === 'danger',
'icon' => 'shield-alert',
'icon' => 'danger',
'group' => 'Danger zone',
// Coolify host (id 0) cannot be deleted. Other servers may still use
// host.docker.internal (e.g. Lima VMs) and must keep the Danger menu.
@@ -224,7 +224,46 @@
));
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
@php
// The main sidebar shows this menu on wide screens (see components/navbar.blade.php).
request()->attributes->set('mainNavContext', [
'label' => 'Servers',
'backHref' => route('server.index'),
'backTitle' => 'Back to the servers',
]);
@endphp
{{-- Rendered twice: in the page (below xl), and teleported into the main sidebar (xl and up).
Both copies keep their own warning state (proxy, Sentinel). --}}
@foreach (['page', 'main-nav'] as $placement)
@if ($placement === 'main-nav')
@teleport('#main-nav-context')
<div class="nav-context-menu"
x-data="{
proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),
traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo()),
proxyNotRunning: @js($proxyNotRunning),
sentinelOutOfSync: @js($server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync'),
sentinelExpiryTimer: null,
scheduleSentinelExpiry(delay) {
clearTimeout(this.sentinelExpiryTimer);
if (!this.sentinelOutOfSync) {
this.sentinelExpiryTimer = setTimeout(() => this.sentinelOutOfSync = true, delay);
}
}
}"
x-init="scheduleSentinelExpiry(@js($sentinelExpiresInMilliseconds))"
@proxy-configuration-state-changed.window="
proxyConfigurationPending = $event.detail.pending;
traefikOutdated = $event.detail.traefikOutdated;
proxyNotRunning = $event.detail.proxyNotRunning;
"
@sentinel-status-changed.window="
sentinelOutOfSync = $event.detail.outOfSync;
scheduleSentinelExpiry($event.detail.expiresInMilliseconds);
">
@else
<aside class="application-settings-navigation in-main-nav min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedServerMenuItems, $serverRouteParameters)) }}"
x-data="{
proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),
@@ -249,9 +288,14 @@
sentinelOutOfSync = $event.detail.outOfSync;
scheduleSentinelExpiry($event.detail.expiresInMilliseconds);
">
@endif
<nav aria-label="Server configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.server' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@class([
'grid gap-0.5',
'grid-cols-2 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]' => $placement === 'page',
'grid-cols-1' => $placement === 'main-nav',
])>
@foreach ($groupedServerMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
@@ -269,7 +313,7 @@
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
@if ($menuItem['disabled'] ?? false)
<span wire:key="server-settings-link-{{ str($menuItem['label'])->slug() }}"
<span wire:key="{{ $placement }}-server-settings-link-{{ str($menuItem['label'])->slug() }}"
class="menu-item cursor-not-allowed opacity-40" aria-disabled="true"
title="Validate the server connection first.">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
@@ -277,7 +321,7 @@
</span>
@continue
@endif
<a wire:key="server-settings-link-{{ str($menuItem['label'])->slug() }}"
<a wire:key="{{ $placement }}-server-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
@@ -303,7 +347,7 @@
@if ($menuItem['active'] && isset($menuItem['children']))
<div class="col-span-full grid grid-cols-2 gap-0.5 border-l border-neutral-200 pl-2 sm:grid-cols-3 xl:grid-cols-1 dark:border-white/[0.08]">
@foreach (collect($menuItem['children'])->filter(fn (array $child): bool => $child['visible'] ?? true) as $child)
<a wire:key="server-settings-child-{{ str($menuItem['label'].'-'.$child['label'])->slug() }}"
<a wire:key="{{ $placement }}-server-settings-child-{{ str($menuItem['label'].'-'.$child['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $child['active']])
@if ($child['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($child['route'], $serverRouteParameters) }}">
@@ -317,4 +361,10 @@
</div>
@endforeach
</nav>
@if ($placement === 'main-nav')
</div>
@endteleport
@else
</aside>
@endif
@endforeach
@@ -3,11 +3,11 @@
'serviceDatabase',
])
<aside class="application-settings-navigation min-w-0 xl:self-start">
<x-main-nav-menu :back-href="route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null])" back-label="Service">
<nav aria-label="Compose resource settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Compose resource</div>
<a class="menu-item" {{ wireNavigate() }}
<a class="menu-item" data-page-only {{ wireNavigate() }}
href="{{ route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null]) }}">
<x-reicon name="logout" class="menu-item-icon rotate-180" />
<span class="menu-item-label">Back to service</span>
@@ -19,4 +19,4 @@
<span class="menu-item-label">General</span>
</a>
</nav>
</aside>
</x-main-nav-menu>
@@ -22,7 +22,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,
@@ -39,7 +39,9 @@
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', '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)
@@ -53,7 +55,7 @@
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
<x-main-nav-menu :back-href="route('dashboard')" back-label="Dashboard" back-title="Back to the dashboard"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
@@ -62,6 +64,7 @@
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
@unless ($groupLabel === 'Danger Zone')
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
@@ -71,20 +74,15 @@
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@endunless
<div @class(['contents', 'xl:block' => $groupLabel === 'Danger Zone']) @unless ($groupLabel === 'Danger Zone') :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'" @endunless>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
@if ($menuItem['label'] === 'Files')
x-data="{ loading: false }"
@click="if (!$event.ctrlKey && !$event.metaKey && !$event.shiftKey && !$event.altKey) loading = true"
x-on:livewire:navigated.window="loading = false"
@endif
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['label'] === 'Files')
<span x-show="loading" x-cloak><x-loading compact /></span>
<x-beta-badge class="ml-auto shrink-0" />
@endif
@if ($menuItem['hasWarning'] ?? false)
@@ -95,4 +93,4 @@
</div>
@endforeach
</nav>
</aside>
</x-main-nav-menu>
@@ -13,40 +13,21 @@
];
@endphp
{{-- The pages are in the Settings submenu of the main sidebar (components/navbar.blade.php). --}}
<section class="application-settings-workspace w-full max-w-none">
<header class="settings-mobile-header xl:hidden">
<h1 class="settings-mobile-title">Instance Settings</h1>
<p class="settings-mobile-description">Configure global settings for this Coolify instance.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems($settingsMenuSections, [])) }}">
<nav aria-label="Instance settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($settingsMenuSections as $section => $menuItems)
<div @class([
'nav-section col-span-full hidden xl:block',
'mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.06]' => !$loop->first,
])>{{ $section }}</div>
@foreach ($menuItems as $menuItem)
<a wire:key="instance-settings-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => request()->routeIs($menuItem['route'])])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@if ($menuItem['route'] === 'settings.oauth' && isset($submenu))
<div class="col-span-full ml-5 border-l border-neutral-200 pl-2 dark:border-white/[0.08]">
{{ $submenu }}
</div>
@endif
@endforeach
@endforeach
</nav>
</aside>
<div class="min-w-0">
{{ $slot }}
</div>
{{-- Feeds the page search with the settings pages. --}}
<div hidden data-settings-search-items="{{ json_encode(settingsSearchItems($settingsMenuSections, [])) }}"></div>
@isset($submenu)
{{-- Sub-items of the current page, under its entry in the main sidebar. --}}
@teleport('#main-nav-active-children')
<div>{{ $submenu }}</div>
@endteleport
@endisset
<div class="min-w-0">
{{ $slot }}
</div>
</section>
@@ -4,11 +4,8 @@
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">Reusable environment variables across resources</p>
</header>
<div class="grid min-w-0 gap-8 xl:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-shared-variables.sidebar />
<div class="min-w-0 xl:mt-3">
{{ $slot }}
</div>
<x-shared-variables.sidebar />
<div class="min-w-0">
{{ $slot }}
</div>
</section>
@@ -8,17 +8,6 @@
];
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Shared variables' => $sharedVariablesMenuItems], [])) }}">
<nav aria-label="Shared variables"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($sharedVariablesMenuItems as $menuItem)
<a wire:key="shared-variables-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
{{-- The pages are in the Shared Variables submenu of the main sidebar (components/navbar.blade.php);
this only feeds the page search. --}}
<div hidden data-settings-search-items="{{ json_encode(settingsSearchItems(['Shared variables' => $sharedVariablesMenuItems], [])) }}"></div>
@@ -13,19 +13,15 @@
</button>
</div>
<div
class="view-toggle w-fit">
<button type="button" @click="viewMode = 'list'; localStorage.setItem('{{ $storageKey }}', 'list')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'list' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="List view" title="List view">
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" @click="viewMode = 'grid'; localStorage.setItem('{{ $storageKey }}', 'grid')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Grid view" title="Grid view">
<x-reicon name="grid" class="size-3.5" />
</button>
{{-- Only the view you can switch to, with its name (as components/canvas-view-toggle.blade.php). --}}
<div class="view-toggle w-fit">
@foreach (['list' => ['List view', 'unordered-list'], 'grid' => ['Grid view', 'grid']] as $mode => [$viewLabel, $icon])
<button type="button" @click="viewMode = '{{ $mode }}'; localStorage.setItem('{{ $storageKey }}', '{{ $mode }}')"
x-show="viewMode !== '{{ $mode }}'" x-cloak title="{{ $viewLabel }}"
class="relative flex h-7.5 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon :name="$icon" class="size-3.5" />
{{ $viewLabel }}
</button>
@endforeach
</div>
</div>
@@ -28,41 +28,21 @@
'label' => 'Danger Zone',
'route' => 'team.danger-zone',
'active' => request()->routeIs('team.danger-zone'),
'icon' => 'shield-alert',
'icon' => 'danger',
'sectionStart' => true,
] : null,
])->filter();
@endphp
<section class="application-settings-workspace w-full max-w-none">
{{-- The team pages are in the Team submenu of the main sidebar (components/navbar.blade.php). --}}
<section class="w-full max-w-none">
<header class="settings-mobile-header xl:hidden">
<h1 class="settings-mobile-title">Team</h1>
<p class="settings-mobile-description">Manage your team, members, and access settings.</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Team' => $teamMenuItems], [])) }}">
<nav aria-label="Team settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Team</div>
@foreach ($teamMenuItems as $menuItem)
@if ($menuItem['sectionStart'] ?? false)
<div class="col-span-full my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endif
<a wire:key="team-settings-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
<div class="min-w-0">
{{ $slot }}
</div>
{{-- Feeds the page search with the team pages. --}}
<div hidden data-settings-search-items="{{ json_encode(settingsSearchItems(['Team' => $teamMenuItems], [])) }}"></div>
<div class="min-w-0">
{{ $slot }}
</div>
</section>
@@ -102,33 +102,6 @@
request()->routeIs('admin.*') => 'Admin',
default => null,
};
// Workspace destinations require an active plan on cloud; unsubscribed users
// only keep profile/appearance and must not see this switcher.
$canUseWorkspaceNav = isSubscribed() || ! isCloud();
$pageDestinations = $canUseWorkspaceNav
? collect([
['label' => 'Dashboard', 'href' => url('/')],
['label' => 'Projects', 'href' => url('/projects')],
auth()->user()?->can('canAccessTerminal')
? ['label' => 'Terminal', 'href' => route('terminal')]
: null,
['label' => 'Servers', 'href' => url('/servers')],
['label' => 'Sources', 'href' => route('source.all')],
['label' => 'Destinations', 'href' => route('destination.index')],
auth()->user()?->isAdmin()
? ['label' => 'Registries', 'href' => route('registries.index')]
: null,
['label' => 'S3 Storage', 'href' => route('storage.index')],
['label' => 'Shared Variables', 'href' => route('shared-variables.index')],
['label' => 'Team', 'href' => route('team.index')],
['label' => 'Notifications', 'href' => route('notifications.email')],
['label' => 'Keys & Tokens', 'href' => route('security.private-key.index')],
['label' => 'Tags', 'href' => route('tags.show')],
isInstanceAdmin()
? ['label' => 'Settings', 'href' => route('settings.index')]
: null,
])->filter()
: collect();
@endphp
<div class="flex min-w-0 items-center gap-0.5 text-[13px]">
{{-- Team --}}
@@ -136,33 +109,7 @@
<livewire:switch-team />
</div>
@if (!$currentProject && $dashboardContext && $canUseWorkspaceNav)
<span class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
<div class="relative min-w-0 shrink" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Switch page"
class="flex h-8 min-w-0 items-center gap-1.5 rounded-md px-2 opacity-70 transition-[background-color,opacity] hover:bg-neutral-100 hover:opacity-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $dashboardContext }}</span>
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24"
fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
class="listbox-panel scrollbar left-0! z-[90]! max-h-80! min-w-52">
<div class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint">
Pages
</div>
@foreach ($pageDestinations as $destination)
<a href="{{ $destination['href'] }}" {{ wireNavigate() }} @click="open = false"
class="listbox-option {{ $destination['label'] === $dashboardContext ? 'bg-neutral-100 font-medium text-black dark:bg-white/[0.07] dark:text-fg' : '' }}">
<span class="min-w-0 flex-1 truncate">{{ $destination['label'] }}</span>
</a>
@endforeach
</div>
</div>
@elseif (!$currentProject && $dashboardContext)
{{-- Static context label only — no links to paid workspace pages. --}}
@if (!$currentProject && $dashboardContext)
<span class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
<span
class="flex h-8 min-w-0 items-center truncate px-2 font-semibold text-black opacity-70 dark:text-fg">{{ $dashboardContext }}</span>
@@ -247,7 +194,7 @@
{{-- Project switcher --}}
<div class="relative min-w-0 shrink" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Switch project"
class="flex items-center gap-1.5 min-w-0 h-8 px-2 rounded-md opacity-70 transition-[background-color,opacity] hover:opacity-100 hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
class="flex items-center gap-1.5 min-w-0 max-w-full h-8 px-2 rounded-md opacity-70 transition-[background-color,opacity] hover:opacity-100 hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentProject->name }}</span>
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
@@ -273,7 +220,7 @@
{{-- Environment switcher --}}
<div class="relative min-w-0 shrink" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Switch environment"
class="flex items-center gap-1.5 min-w-0 h-8 px-2 rounded-md opacity-70 transition-[background-color,opacity] hover:opacity-100 hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
class="flex items-center gap-1.5 min-w-0 max-w-full h-8 px-2 rounded-md opacity-70 transition-[background-color,opacity] hover:opacity-100 hover:bg-neutral-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentEnvironment->name }}</span>
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
@@ -85,22 +85,6 @@
<x-theme-controls variant="menu" />
</div>
<button type="button" class="listbox-option w-full" @click="toggleAutoCollapse()"
:aria-pressed="autoCollapse" title="Collapse the sidebar on pages that have a settings menu">
<span class="flex items-center gap-2">
<svg class="size-4 opacity-80" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="4" stroke="currentColor" stroke-width="1.6" />
<path d="M16 9v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
Auto-collapse sidebar
</span>
<svg x-show="autoCollapse" x-cloak class="size-4 shrink-0 text-black dark:text-fg" viewBox="0 0 24 24"
fill="none" aria-hidden="true">
<path d="M5 12l5 5L20 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<div class="my-1 h-px bg-neutral-200 dark:bg-white/[0.07]"></div>
<livewire:settings-dropdown trigger="account-menu" />
+6 -34
View File
@@ -9,51 +9,22 @@
@auth
<div x-data="{
open: false,
hasSidebarPreference: localStorage.getItem('sidebarCollapsed') !== null,
userCollapsed: localStorage.getItem('sidebarCollapsed') === 'true',
autoCollapse: localStorage.getItem('sidebarAutoCollapse') !== 'false',
hasSecondBar: false,
collapsed: false,
// New users start collapsed.
collapsed: localStorage.getItem('sidebarCollapsed') !== 'false',
pageWidth: localStorage.getItem('pageWidth') || 'centered',
sidebarReady: false,
init() {
this.applyCollapsed(false);
this.$nextTick(() => {
requestAnimationFrame(() => {
this.sidebarReady = true;
});
});
},
targetCollapsed() {
this.hasSecondBar = !!document.querySelector('.application-settings-navigation');
// New users start collapsed; auto-collapse still applies on pages with a settings menu.
const preferred = this.hasSidebarPreference ? this.userCollapsed : true;
return preferred || (this.autoCollapse && this.hasSecondBar);
},
applyCollapsed(animate) {
const target = this.targetCollapsed();
if (target === this.collapsed) return;
if (animate) {
// Let the new page paint at the current width, then animate the
// slide a frame later so the auto-collapse reads as a motion.
this.sidebarReady = true;
requestAnimationFrame(() => requestAnimationFrame(() => { this.collapsed = target; }));
} else {
this.collapsed = target;
}
},
toggleSidebar() {
this.collapsed = !this.collapsed;
this.hasSidebarPreference = true;
this.userCollapsed = this.collapsed;
localStorage.setItem('sidebarCollapsed', this.userCollapsed);
},
toggleAutoCollapse() {
this.autoCollapse = !this.autoCollapse;
localStorage.setItem('sidebarAutoCollapse', this.autoCollapse);
this.applyCollapsed(true);
localStorage.setItem('sidebarCollapsed', this.collapsed);
}
}" @open-global-search.window="open = false" @page-width-changed.window="pageWidth = $event.detail" x-on:livewire:navigated.window="applyCollapsed(true)"
}" @open-global-search.window="open = false" @page-width-changed.window="pageWidth = $event.detail"
:style="{ '--sidebar-w': collapsed ? '4rem' : '14rem' }" x-cloak
@class(['app-shell dark:text-inherit text-black', 'flex h-dvh flex-col' => $fullBleed ?? false])>
{{-- ============ DESKTOP TOP BAR ============ --}}
@@ -98,6 +69,7 @@
{{-- Page toolbars (the canvases) dock here on desktop. --}}
<div id="page-toolbar-slot" class="flex shrink-0 items-center empty:hidden"></div>
<div id="configuration-warning-hud-slot" class="relative shrink-0"></div>
<livewire:deployments-indicator variant="topbar" />
{{-- Resource actions dock here on desktop. --}}
<div id="resource-action-hud-slot" x-ref="resourceActions" class="hidden shrink-0 items-center xl:flex"></div>
</div>
@@ -150,7 +122,7 @@
<div class="app-sidebar hidden lg:fixed lg:top-12 lg:bottom-0 lg:left-0 lg:z-40 lg:flex lg:flex-col min-w-0"
:class="[collapsed ? 'lg:w-16' : 'lg:w-56', sidebarReady ? 'transition-[width] duration-200' : '']">
<div class="flex min-h-0 grow min-w-0 flex-col overflow-visible">
<x-navbar deployments-indicator />
<x-navbar desktop />
</div>
</div>
+207 -35
View File
@@ -12,6 +12,38 @@
$dashboardProjects = $projects->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
$dashboardServers = $servers->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
$hasTrafficAnalytics = $servers->contains(fn ($server) => $server->isTrafficAnalyticsEnabled());
$projectResourceCounts = $dashboardProjects->mapWithKeys(fn ($project) => [$project->id => collect([
$project->applications_count,
$project->services_count,
$project->postgresqls_count,
$project->redis_count,
$project->keydbs_count,
$project->dragonflies_count,
$project->clickhouses_count,
$project->mongodbs_count,
$project->mysqls_count,
$project->mariadbs_count,
$project->sqlites_count,
])->sum()]);
$serverStatuses = $dashboardServers->mapWithKeys(function ($server) {
$proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo());
$sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync';
return [$server->id => match (true) {
$server->settings->force_disabled => ['Disabled', 'error'],
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
! $server->settings->is_reachable => ['Unreachable', 'error'],
! $server->settings->is_usable => ['Not ready', 'warning'],
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
default => ['Ready', 'success'],
}];
});
$iconButton = 'flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg';
// Same look as $iconButton, sized to the 20px section title it sits next to.
$titleIconButton = 'flex size-5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg';
$tableShell = 'overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]';
$tableHead = 'border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint';
$tableRow = 'min-h-14 items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.025]';
@endphp
<div class="flex min-w-0 flex-col gap-8">
@@ -32,38 +64,59 @@
</div>
@endif
{{-- Wide screens: activity takes the left third, projects and servers the other two thirds.
It stays one column while the left side has no section (no deployments, no traffic). --}}
<div class="grid min-w-0 gap-8 xl:items-start xl:has-[.dashboard-activity_section]:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
<div class="dashboard-activity flex min-w-0 flex-col gap-8">
<livewire:dashboard.active-deployments />
@if ($hasTrafficAnalytics)
<livewire:dashboard.traffic-analytics />
@endif
</div>
<section class="mb-0! min-w-0">
<x-section-heading title="Projects" subtitle="Your deployment workspaces"
:href="route('project.index')" />
<div class="flex min-w-0 flex-col gap-8">
<section class="@container mb-0! min-w-0" x-data="{
viewMode: @js($this->listView('dashboard_projects', 'table')),
setViewMode(mode) {
this.viewMode = mode;
$wire.saveListView('dashboard_projects', mode);
}
}">
<x-section-heading title="Projects"
:href="route('project.index')">
{{-- Projects has no sidebar entry, so the dashboard is where new projects start. --}}
@can('createAnyResource')
<x-slot:titleAction>
<x-modal-input title="New Project">
<x-slot:content>
<button type="button" class="{{ $titleIconButton }}" title="New project"
aria-label="New project">
<x-reicon name="plus" class="size-3" />
</button>
</x-slot:content>
<livewire:project.add-empty />
</x-modal-input>
</x-slot:titleAction>
@endcan
@if ($dashboardProjects->isNotEmpty())
<x-slot:actions>
<x-canvas-view-toggle />
</x-slot:actions>
@endif
</x-section-heading>
@if ($dashboardProjects->isEmpty())
<x-empty title="No projects yet"
description="Create your first deployment workspace from Projects."
description="Create your first deployment workspace."
icon-name="projects" size="sm" />
@else
<div class="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div x-cloak x-show="viewMode === 'grid'"
class="grid min-w-0 grid-cols-1 gap-3 @lg:grid-cols-2 @4xl:grid-cols-4">
@foreach ($dashboardProjects as $project)
@php
$firstEnvironment = $project->environments->first();
$resourceCount = collect([
$project->applications_count,
$project->services_count,
$project->postgresqls_count,
$project->redis_count,
$project->keydbs_count,
$project->dragonflies_count,
$project->clickhouses_count,
$project->mongodbs_count,
$project->mysqls_count,
$project->mariadbs_count,
$project->sqlites_count,
])->sum();
$resourceCount = $projectResourceCounts[$project->id];
@endphp
<article
@@ -118,7 +171,7 @@
'environment_uuid' => $firstEnvironment->uuid,
]) }}"
{{ wireNavigate() }}
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="{{ $iconButton }}"
title="Add resource"
aria-label="Add resource to {{ $project->name }}">
<x-reicon name="plus" class="size-3" />
@@ -128,7 +181,7 @@
@can('update', $project)
<a href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}"
{{ wireNavigate() }}
class="flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
class="{{ $iconButton }}"
title="Project settings"
aria-label="Open settings for {{ $project->name }}">
<x-reicon name="settings" class="size-3" />
@@ -139,12 +192,101 @@
</article>
@endforeach
</div>
<div x-show="viewMode === 'table'" class="{{ $tableShell }}">
<div class="grid grid-cols-[minmax(0,1fr)_7rem_4rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4rem] {{ $tableHead }}">
<div>Project</div>
<div>Contents</div>
<div class="hidden @2xl:block">Description</div>
<div></div>
</div>
@foreach ($dashboardProjects as $project)
@php
$firstEnvironment = $project->environments->first();
@endphp
<div wire:key="dashboard-project-row-{{ $project->uuid }}"
class="relative grid grid-cols-[minmax(0,1fr)_7rem_4rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4rem] {{ $tableRow }}">
<a href="{{ $project->navigateTo() }}" {{ wireNavigate() }}
class="absolute inset-0" aria-label="Open {{ $project->name }}"></a>
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim">
@if ($project->icon_path)
<img src="{{ project_icon_url($project) }}" alt="{{ $project->name }} icon"
class="h-full w-full rounded-lg object-cover">
@else
<x-reicon name="projects" class="size-4" />
@endif
</div>
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $project->name }}
</p>
</div>
<div class="relative z-10 flex w-fit items-center gap-3 font-medium text-neutral-600 dark:text-fg-dim">
<span class="inline-flex items-center gap-1" data-tooltip="Environments"
aria-label="Environments">
<x-reicon name="layers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $project->environments->count() }}
</span>
<span class="inline-flex items-center gap-1" data-tooltip="Resources"
aria-label="Resources">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $projectResourceCounts[$project->id] }}
</span>
</div>
<p class="hidden truncate text-neutral-500 @2xl:block dark:text-fg-dim">
{{ filled($project->description) ? $project->description : '-' }}
</p>
<div class="relative z-10 flex items-center justify-end gap-0.5">
@if ($firstEnvironment)
@can('createAnyResource')
<a href="{{ route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
]) }}"
{{ wireNavigate() }} class="{{ $iconButton }}" title="Add resource"
aria-label="Add resource to {{ $project->name }}">
<x-reicon name="plus" class="size-3" />
</a>
@endcan
@endif
@can('update', $project)
<a href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}"
{{ wireNavigate() }} class="{{ $iconButton }}" title="Project settings"
aria-label="Open settings for {{ $project->name }}">
<x-reicon name="settings" class="size-3" />
</a>
@endcan
</div>
</div>
@endforeach
</div>
@endif
</section>
<section class="mb-0! min-w-0">
<x-section-heading title="Servers" subtitle="Infrastructure available for deployments"
:href="route('server.index')" />
<section class="@container mb-0! min-w-0" x-data="{
viewMode: @js($this->listView('dashboard_servers', 'table')),
setViewMode(mode) {
this.viewMode = mode;
$wire.saveListView('dashboard_servers', mode);
}
}">
<x-section-heading title="Servers"
:href="route('server.index')">
@can('createAnyResource')
<x-slot:titleAction>
<a href="{{ route('server.create') }}" {{ wireNavigate() }} class="{{ $titleIconButton }}"
title="New server" aria-label="New server">
<x-reicon name="plus" class="size-3" />
</a>
</x-slot:titleAction>
@endcan
@if ($dashboardServers->isNotEmpty())
<x-slot:actions>
<x-canvas-view-toggle />
</x-slot:actions>
@endif
</x-section-heading>
@if ($dashboardServers->isEmpty())
@if ($privateKeys->isEmpty())
@@ -177,20 +319,11 @@
</x-empty>
@endif
@else
<div class="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div x-cloak x-show="viewMode === 'grid'"
class="grid min-w-0 grid-cols-1 gap-3 @lg:grid-cols-2 @4xl:grid-cols-4">
@foreach ($dashboardServers as $server)
@php
$proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo());
$sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync';
[$serverStatus, $serverStatusType] = match (true) {
$server->settings->force_disabled => ['Disabled', 'error'],
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
! $server->settings->is_reachable => ['Unreachable', 'error'],
! $server->settings->is_usable => ['Not ready', 'warning'],
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
default => ['Ready', 'success'],
};
[$serverStatus, $serverStatusType] = $serverStatuses[$server->id];
@endphp
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}"
@@ -232,7 +365,46 @@
</a>
@endforeach
</div>
<div x-show="viewMode === 'table'" class="{{ $tableShell }}">
<div class="grid grid-cols-[minmax(0,1fr)_9.5rem] gap-4 @2xl:min-w-[480px] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_9.5rem] {{ $tableHead }}">
<div>Server</div>
<div class="hidden @2xl:block">Description</div>
<div>Status</div>
</div>
@foreach ($dashboardServers as $server)
@php
[$serverStatus, $serverStatusType] = $serverStatuses[$server->id];
@endphp
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
wire:key="dashboard-server-row-{{ $server->uuid }}"
class="grid grid-cols-[minmax(0,1fr)_9.5rem] gap-4 @2xl:min-w-[480px] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_9.5rem] hover:no-underline {{ $tableRow }}">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.035] dark:text-fg-dim">
<x-reicon name="servers" class="size-4" />
</div>
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $server->name }}
</p>
</div>
<p class="hidden truncate text-neutral-500 @2xl:block dark:text-fg-dim">
{{ filled($server->description) ? $server->description : '-' }}
</p>
<div class="flex items-center gap-2 text-[11px] font-medium text-neutral-600 dark:text-fg-dim">
<span @class([
'size-2 shrink-0 rounded-full',
'bg-green-500 dark:bg-green-400' => $serverStatusType === 'success',
'bg-orange-500 dark:bg-warning' => $serverStatusType === 'warning',
'bg-red-500 dark:bg-red-400' => $serverStatusType === 'error',
])></span>
{{ $serverStatus }}
</div>
</a>
@endforeach
</div>
@endif
</section>
</div>
</div>
</div>
</div>
@@ -24,10 +24,18 @@
$hasAnyDeployments = $hasActiveDeployments || $hasRecentDeployments;
@endphp
<div wire:poll.3000ms="refreshDeployments" @class(['mb-0! min-w-0' => $hasAnyDeployments, 'hidden' => ! $hasAnyDeployments])>
{{-- The dashboard's canvas link sits in the Deployments heading, or
alone at the top right when there are no deployments. --}}
<div wire:poll.3000ms="refreshDeployments" @class(['min-w-0', 'mb-0!' => $hasAnyDeployments, '-mb-4' => ! $hasAnyDeployments])>
@if ($hasAnyDeployments)
<section class="mb-0! min-w-0">
<x-section-heading title="Deployments" subtitle="Active and recent deployment activity" />
{{-- Container breakpoints: the table needs room, which the dashboard column may not have. --}}
<section class="@container mb-0! min-w-0">
<x-section-heading title="Deployments">
<x-slot:actions>
<x-canvas-view-toggle :views="[]"
:list-url="route('dashboard')" :canvas-url="route('dashboard.canvas')" />
</x-slot:actions>
</x-section-heading>
<div class="flex min-w-0 flex-col gap-4">
@if ($hasActiveDeployments)
@@ -43,7 +51,7 @@
</div>
<div
class="dashboard-deployment-table-grid hidden items-center gap-4 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 md:grid dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
class="dashboard-deployment-table-grid hidden items-center gap-4 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 @2xl:grid dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<span>Application</span>
<span>Environment</span>
<span>Server</span>
@@ -61,7 +69,7 @@
<a wire:key="dashboard-active-deployment-{{ $deployment->deployment_uuid }}"
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 focus-visible:z-10 focus-visible:bg-warning/10 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-warning focus-visible:ring-offset-0 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025] dark:focus-visible:bg-warning/10">
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 focus-visible:z-10 focus-visible:bg-warning/10 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-warning focus-visible:ring-offset-0 @2xl:grid @2xl:items-center @2xl:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025] dark:focus-visible:bg-warning/10">
<div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $deployment->application_name }}
@@ -73,20 +81,20 @@
@endif
</div>
<p class="hidden truncate text-[12px] text-neutral-500 md:block dark:text-fg-dim">
<p class="hidden truncate text-[12px] text-neutral-500 @2xl:block dark:text-fg-dim">
{{ $environmentPath ?: '-' }}
</p>
<p class="hidden truncate text-[12px] text-neutral-500 md:block dark:text-fg-dim">
<p class="hidden truncate text-[12px] text-neutral-500 @2xl:block dark:text-fg-dim">
{{ $deployment->server_name ?: '-' }}
</p>
<span class="hidden md:block">
<span class="hidden @2xl:block">
<x-status-badge :status="$deploymentStatus" :type="$deploymentStatusType" />
</span>
<p class="hidden truncate text-[12px] text-neutral-500 md:block dark:text-fg-dim">
<p class="hidden truncate text-[12px] text-neutral-500 @2xl:block dark:text-fg-dim">
{{ $deployment->created_at?->diffForHumans() ?? '-' }}
</p>
<div class="mt-3 flex min-w-0 items-center gap-3 md:hidden">
<div class="mt-3 flex min-w-0 items-center gap-3 @2xl:hidden">
<x-status-badge :status="$deploymentStatus" :type="$deploymentStatusType" />
<p class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $environmentPath ?: '-' }}
@@ -112,7 +120,7 @@
</div>
<div
class="dashboard-deployment-table-grid hidden items-center gap-4 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 md:grid dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
class="dashboard-deployment-table-grid hidden items-center gap-4 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 @2xl:grid dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<span>Application</span>
<span>Environment</span>
<span>Server</span>
@@ -130,7 +138,7 @@
<a wire:key="dashboard-recent-deployment-{{ $deployment->deployment_uuid }}"
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 focus-visible:z-10 focus-visible:bg-warning/10 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-warning focus-visible:ring-offset-0 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025] dark:focus-visible:bg-warning/10">
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 focus-visible:z-10 focus-visible:bg-warning/10 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-warning focus-visible:ring-offset-0 @2xl:grid @2xl:items-center @2xl:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025] dark:focus-visible:bg-warning/10">
<div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $deployment->application_name }}
@@ -142,20 +150,20 @@
@endif
</div>
<p class="hidden truncate text-[12px] text-neutral-500 md:block dark:text-fg-dim">
<p class="hidden truncate text-[12px] text-neutral-500 @2xl:block dark:text-fg-dim">
{{ $environmentPath ?: '-' }}
</p>
<p class="hidden truncate text-[12px] text-neutral-500 md:block dark:text-fg-dim">
<p class="hidden truncate text-[12px] text-neutral-500 @2xl:block dark:text-fg-dim">
{{ $deployment->server_name ?: '-' }}
</p>
<span class="hidden md:block">
<span class="hidden @2xl:block">
<x-status-badge :status="$deploymentStatus" :type="$deploymentStatusType" />
</span>
<p class="hidden truncate text-[12px] text-neutral-500 md:block dark:text-fg-dim">
<p class="hidden truncate text-[12px] text-neutral-500 @2xl:block dark:text-fg-dim">
{{ $deployment->created_at?->diffForHumans() ?? '-' }}
</p>
<div class="mt-3 flex min-w-0 items-center gap-3 md:hidden">
<div class="mt-3 flex min-w-0 items-center gap-3 @2xl:hidden">
<x-status-badge :status="$deploymentStatus" :type="$deploymentStatusType" />
<p class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $environmentPath ?: '-' }}
@@ -169,5 +177,10 @@
@endif
</div>
</section>
@else
<div class="flex justify-end">
<x-canvas-view-toggle :views="[]"
:list-url="route('dashboard')" :canvas-url="route('dashboard.canvas')" />
</div>
@endif
</div>
@@ -0,0 +1,14 @@
<div class="flex h-full flex-col">
<x-slot:title>
Dashboard > Canvas | Coolify
</x-slot>
{{-- data-poll: deployments start without a broadcast event, so the canvas reloads while it is open. --}}
<div data-environment-canvas data-endpoint="/dashboard/canvas" data-poll="5"
data-team="{{ $teamId }}" data-user="{{ auth()->id() }}" class="relative min-h-0 flex-1" wire:ignore>
{{-- The canvas moves this group into its toolbar. --}}
<div data-ec-views class="flex items-center gap-2">
<x-canvas-view-toggle on-canvas :views="['list' => ['List view', 'unordered-list']]"
:list-url="route('dashboard')" :canvas-url="route('dashboard.canvas')" />
</div>
</div>
</div>
@@ -1,21 +1,19 @@
{{-- Rendered twice: in the desktop sidebar footer and in the mobile top bar. `.visible` pauses
{{-- Rendered twice: in the desktop top bar and in the mobile top bar. `.visible` pauses
polling for the copy hidden by the current breakpoint (a display:none element has no box). --}}
<div wire:poll.3000ms.visible x-data="{ expanded: false }" x-on:click.outside="expanded = false"
x-on:keydown.escape.window="expanded = false" @class(['relative' => $variant === 'sidebar'])>
x-on:keydown.escape.window="expanded = false" @class(['relative' => $variant === 'topbar'])>
@if ($this->deploymentCount > 0)
@php
$deploymentLabel = $this->deploymentCount.' '.Str::plural('deployment', $this->deploymentCount);
@endphp
{{-- Deployment list: opens beside the sidebar on desktop, fixed to its button so the
scrolling sidebar does not clip it. On mobile the root is not positioned,
so the list anchors to the sticky top bar and spans its width. --}}
{{-- Deployment list: on desktop it opens below the button, aligned to its right edge.
On mobile the root is not positioned, so the list anchors to the sticky top bar
and spans its width. --}}
<div x-show="expanded" x-cloak x-transition.opacity.duration.150ms
@if ($variant === 'sidebar') x-anchor.right-end.fixed.offset.20="$refs.deploymentsButton" @endif
@class([
'surface-popover z-60 overflow-hidden rounded-xl',
'w-[22rem]' => $variant === 'sidebar',
'absolute' => $variant === 'mobile',
'inset-x-4 top-full mt-2' => $variant === 'mobile',
'surface-popover absolute top-full z-60 mt-2 overflow-hidden rounded-xl',
'right-0 w-[22rem]' => $variant === 'topbar',
'inset-x-4' => $variant === 'mobile',
])>
<div class="max-h-96 space-y-1 overflow-y-auto p-2 scrollbar">
@foreach ($this->deployments as $deployment)
@@ -72,38 +70,18 @@
@endforeach
</div>
</div>
@if ($variant === 'sidebar')
<button type="button" x-ref="deploymentsButton" x-on:click="expanded = !expanded" title="{{ $deploymentLabel }}"
aria-label="Active deployments" :aria-expanded="expanded.toString()"
class="menu-item w-full text-left text-coollabs dark:text-warning"
:class="[collapsed && 'lg:justify-center lg:px-0', expanded && 'menu-item-active']">
<span class="relative inline-flex">
<svg class="menu-item-icon animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span x-show="collapsed" x-cloak
class="absolute -right-1.5 -top-1.5 flex min-w-3.5 items-center justify-center rounded-full bg-coollabs px-1 text-[9px] font-semibold leading-3.5 text-white dark:bg-warning dark:text-black">{{ $this->deploymentCount }}</span>
</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">{{ $deploymentLabel }}</span>
</button>
@else
<button type="button" x-on:click="expanded = !expanded" title="{{ $deploymentLabel }}"
aria-label="Active deployments" :aria-expanded="expanded.toString()"
class="flex h-9 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-coollabs transition-colors hover:bg-neutral-100 dark:text-warning dark:hover:bg-white/[0.06]">
<svg class="size-3.5 shrink-0 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span>{{ $this->deploymentCount }}</span>
</button>
@endif
<button type="button" x-on:click="expanded = !expanded" title="{{ $deploymentLabel }}"
aria-label="Active deployments" :aria-expanded="expanded.toString()"
:class="expanded && 'bg-neutral-100 dark:bg-white/[0.06]'"
class="flex h-9 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-coollabs transition-colors hover:bg-neutral-100 dark:text-warning dark:hover:bg-white/[0.06]">
<svg class="size-3.5 shrink-0 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span>{{ $this->deploymentCount }}</span>
</button>
@endif
</div>
@@ -58,7 +58,7 @@
localStorage.setItem('coolify-destinations-view', mode);
}
}">
@include('livewire.shared.list-search-controls', ['placeholder' => 'Search destinations', 'singular' => 'destination', 'plural' => 'destinations'])
@include('livewire.shared.list-search-controls', ['placeholder' => 'Search destinations'])
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($destinations as $destination)
@@ -17,17 +17,19 @@
'label' => 'Danger Zone',
'route' => 'destination.danger',
'active' => request()->routeIs('destination.danger'),
'icon' => 'shield-alert',
'icon' => 'danger',
],
])->filter();
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
<x-main-nav-menu :back-href="route('destination.index')" back-label="Destinations" back-title="Back to the destinations"
data-settings-search-items="{{ json_encode(settingsSearchItems(['Destination' => $destinationMenuItems], $destinationRouteParameters)) }}">
<nav aria-label="Destination settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Settings</div>
@foreach ($destinationMenuItems as $menuItem)
@if ($menuItem['label'] === 'Danger Zone')
<div class="col-span-full my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endif
<a wire:key="destination-settings-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route'], $destinationRouteParameters) }}">
@@ -36,4 +38,4 @@
</a>
@endforeach
</nav>
</aside>
</x-main-nav-menu>
+108 -112
View File
@@ -8,117 +8,6 @@
@close-email-change-modal.window="emailModalOpen = false">
<x-slot:title>Profile | Coolify</x-slot>
<div class="mt-8 flex w-full max-w-none flex-col gap-6 lg:mt-3">
<section class="application-settings-section" x-data="{
preview: null,
processing: false,
uploadError: null,
async prepareAvatar(event) {
const file = event.target.files?.[0];
if (!file) return;
this.processing = true;
this.uploadError = null;
try {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const image = await new Promise((resolve, reject) => {
const element = new Image();
element.onload = () => resolve(element);
element.onerror = reject;
element.src = dataUrl;
});
const cropSize = Math.min(image.naturalWidth, image.naturalHeight);
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const context = canvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, 256, 256);
context.drawImage(
image,
(image.naturalWidth - cropSize) / 2,
(image.naturalHeight - cropSize) / 2,
cropSize,
cropSize,
0,
0,
256,
256,
);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(value => value ? resolve(value) : reject(new Error('JPEG compression failed')), 'image/jpeg', 0.8);
});
const previewUrl = URL.createObjectURL(blob);
const compressed = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
this.$wire.upload('avatar', compressed, async () => {
try {
const uploaded = await this.$wire.uploadAvatar();
if (uploaded) {
if (this.preview) URL.revokeObjectURL(this.preview);
this.preview = previewUrl;
} else {
URL.revokeObjectURL(previewUrl);
}
} catch (error) {
URL.revokeObjectURL(previewUrl);
this.uploadError = 'The image could not be uploaded.';
} finally {
this.processing = false;
}
}, () => {
URL.revokeObjectURL(previewUrl);
this.processing = false;
this.uploadError = 'The image could not be uploaded.';
});
} catch (error) {
this.processing = false;
this.uploadError = 'The image could not be processed in this browser.';
}
},
}">
<div class="application-settings-section-header">
<div>
<h2>Profile picture</h2>
<p>Upload a JPG, PNG, or WebP image.</p>
</div>
</div>
<div class="application-settings-section-body flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-200 text-2xl font-semibold text-neutral-700 dark:bg-white/[0.1] dark:text-fg">
<img x-cloak x-show="preview" :src="preview" alt="Profile picture preview"
class="h-full w-full object-cover">
@if (auth()->user()->avatar_path)
<img src="{{ profile_avatar_url(auth()->user()) }}"
x-show="!preview" alt="{{ auth()->user()->name }}" class="h-full w-full object-cover">
@else
<span x-show="!preview">
{{ strtoupper(mb_substr(auth()->user()->name ?: auth()->user()->email, 0, 1)) }}
</span>
@endif
</div>
<div class="flex min-w-0 flex-1 flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<input x-ref="avatarInput" type="file" x-on:change="prepareAvatar($event)"
accept="image/jpeg,image/png,image/webp" class="hidden">
<x-forms.button type="button" x-on:click="$refs.avatarInput.click()"
x-bind:disabled="processing">
<span x-text="processing ? 'Uploading…' : 'Browse…'"></span>
</x-forms.button>
@if (auth()->user()->avatar_path)
<x-forms.button type="button" wire:click="removeAvatar" x-bind:disabled="processing"
isError>Remove</x-forms.button>
@endif
</div>
<p x-cloak x-show="uploadError" x-text="uploadError" class="text-xs text-red-500"></p>
@error('avatar')
<p class="text-xs text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
</section>
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
@@ -126,10 +15,117 @@
<div class="application-settings-section-header">
<div>
<h2>Profile details</h2>
<p>Your display name and verified sign-in address.</p>
<p>Your avatar, display name, and verified sign-in address.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2" x-data="{
preview: null,
processing: false,
uploadError: null,
async prepareAvatar(event) {
const file = event.target.files?.[0];
if (!file) return;
this.processing = true;
this.uploadError = null;
try {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const image = await new Promise((resolve, reject) => {
const element = new Image();
element.onload = () => resolve(element);
element.onerror = reject;
element.src = dataUrl;
});
const cropSize = Math.min(image.naturalWidth, image.naturalHeight);
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const context = canvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, 256, 256);
context.drawImage(
image,
(image.naturalWidth - cropSize) / 2,
(image.naturalHeight - cropSize) / 2,
cropSize,
cropSize,
0,
0,
256,
256,
);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(value => value ? resolve(value) : reject(new Error('JPEG compression failed')), 'image/jpeg', 0.8);
});
const previewUrl = URL.createObjectURL(blob);
const compressed = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
this.$wire.upload('avatar', compressed, async () => {
try {
const uploaded = await this.$wire.uploadAvatar();
if (uploaded) {
if (this.preview) URL.revokeObjectURL(this.preview);
this.preview = previewUrl;
} else {
URL.revokeObjectURL(previewUrl);
}
} catch (error) {
URL.revokeObjectURL(previewUrl);
this.uploadError = 'The image could not be uploaded.';
} finally {
this.processing = false;
}
}, () => {
URL.revokeObjectURL(previewUrl);
this.processing = false;
this.uploadError = 'The image could not be uploaded.';
});
} catch (error) {
this.processing = false;
this.uploadError = 'The image could not be processed in this browser.';
}
},
}">
<div class="mb-2 text-sm font-medium leading-4">Avatar</div>
<div class="flex items-center gap-4">
<div class="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-neutral-200 text-2xl font-semibold text-neutral-700 dark:bg-white/[0.1] dark:text-fg">
<img x-cloak x-show="preview" :src="preview" alt="Profile picture preview"
class="h-full w-full object-cover">
@if (auth()->user()->avatar_path)
<img src="{{ profile_avatar_url(auth()->user()) }}"
x-show="!preview" alt="{{ auth()->user()->name }}" class="h-full w-full object-cover">
@else
<span x-show="!preview">
{{ strtoupper(mb_substr(auth()->user()->name ?: auth()->user()->email, 0, 1)) }}
</span>
@endif
</div>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div class="flex flex-wrap gap-2">
<input x-ref="avatarInput" type="file" x-on:change="prepareAvatar($event)"
accept="image/jpeg,image/png,image/webp" class="hidden">
<x-forms.button type="button" x-on:click="$refs.avatarInput.click()"
x-bind:disabled="processing">
<span x-text="processing ? 'Uploading…' : 'Browse…'"></span>
</x-forms.button>
@if (auth()->user()->avatar_path)
<x-forms.button type="button" wire:click="removeAvatar" x-bind:disabled="processing"
isError>Remove</x-forms.button>
@endif
</div>
<p class="text-xs text-neutral-500 dark:text-fg-dim">JPG, PNG, or WebP. Shown in the user menu and team member lists.</p>
<p x-cloak x-show="uploadError" x-text="uploadError" class="text-xs text-red-500"></p>
@error('avatar')
<p class="text-xs text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
</div>
<x-forms.input id="name" label="Name" required />
<div class="flex items-end gap-2">
<x-forms.input id="email" label="Email" readonly />
@@ -11,7 +11,7 @@
<div class="min-w-0">
<livewire:project.shared.storages.volume-backups :storage="$backup->backupable"
:resource="$application" :section="$section"
:resource="$application" :section="$section" :actions-in-header="true"
wire:key="volume-backup-{{ $backup->uuid }}-{{ $section }}" />
</div>
</div>
@@ -30,8 +30,6 @@
<livewire:project.shared.destination :resource="$application" />
@elseif ($currentRoute === 'project.application.scheduled-tasks.show')
<livewire:project.shared.scheduled-task.all :resource="$application" />
@elseif ($currentRoute === 'project.application.scheduled-tasks')
<livewire:project.shared.scheduled-task.show />
@elseif ($currentRoute === 'project.application.webhooks')
<livewire:project.shared.webhooks :resource="$application" />
@elseif ($currentRoute === 'project.application.preview-deployments')
@@ -1,13 +1,5 @@
<div class="flex flex-col gap-6">
@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')
@@ -1,10 +1,11 @@
<form wire:submit="submit">
<form wire:submit="submit" class="flex flex-col gap-6">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Backup schedule"
description="Choose what to back up, when it runs, and how long it may run.">
<x-slot:actions>
<div class="flex items-center gap-2">
{{-- Below xl the header bar is hidden, so keep the buttons here. --}}
<div @class(['flex items-center gap-2', 'xl:hidden' => $actionsInHeader])>
@if (! $backupEnabled)
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled" isHighlighted>
@@ -98,4 +99,7 @@
</div>
</div>
</x-application.settings-section>
@include('livewire.project.database.backup-edit.s3')
@include('livewire.project.database.backup-edit.retention')
</form>
@@ -1,34 +1,30 @@
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Retention"
description="The first reached limit removes the oldest backup. Use 0 for unlimited retention.">
<div class="space-y-6">
<div>
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Local backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="databaseBackupRetentionAmountLocally"
type="number" min="0" helper="Maximum number of recent local backups." required />
<x-forms.input label="Days to keep" id="databaseBackupRetentionDaysLocally"
type="number" min="0" helper="Remove local backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)"
id="databaseBackupRetentionMaxStorageLocally" type="number" min="0" step="any"
helper="Remove oldest local backups after this total size is reached." required />
</div>
</div>
<div class="border-t border-neutral-200 pt-6 dark:border-white/[0.06]">
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">S3 backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="databaseBackupRetentionAmountS3"
type="number" min="0" helper="Maximum number of recent S3 backups." required />
<x-forms.input label="Days to keep" id="databaseBackupRetentionDaysS3"
type="number" min="0" helper="Remove S3 backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
type="number" min="0" step="any"
helper="Remove oldest S3 backups after this total size is reached." required />
</div>
<x-application.settings-section title="Retention"
description="The first reached limit removes the oldest backup. Use 0 for unlimited retention.">
<div class="space-y-6">
<div>
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Local backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="databaseBackupRetentionAmountLocally"
type="number" min="0" helper="Maximum number of recent local backups." required />
<x-forms.input label="Days to keep" id="databaseBackupRetentionDaysLocally"
type="number" min="0" helper="Remove local backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)"
id="databaseBackupRetentionMaxStorageLocally" type="number" min="0" step="any"
helper="Remove oldest local backups after this total size is reached." required />
</div>
</div>
</x-application.settings-section>
</form>
<div class="border-t border-neutral-200 pt-6 dark:border-white/[0.06]">
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">S3 backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="databaseBackupRetentionAmountS3"
type="number" min="0" helper="Maximum number of recent S3 backups." required />
<x-forms.input label="Days to keep" id="databaseBackupRetentionDaysS3"
type="number" min="0" helper="Remove S3 backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
type="number" min="0" step="any"
helper="Remove oldest S3 backups after this total size is reached." required />
</div>
</div>
</div>
</x-application.settings-section>
@@ -10,33 +10,29 @@
</x-empty>
</x-application.settings-section>
@else
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="S3 storage"
description="Choose where remote backups are stored and whether local copies are retained.">
<x-slot:actions>
@if (! $saveS3)
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" isHighlighted>Enable S3</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3">Disable S3</x-forms.button>
@endif
</x-slot:actions>
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="s3StorageIds" label="S3 storages" multiple live portal
helper="Each backup is uploaded to every selected storage." placeholder="Select S3 storages"
:options="$availableS3Storages->map(fn ($storage) => [
'value' => $storage->id,
'label' => $storage->name.($storage->is_usable ? '' : ' (unavailable)'),
])->all()" />
<x-forms.listbox id="disableLocalBackup" label="Local copy" portal :disabled="! $saveS3"
:options="[
['value' => false, 'label' => 'Keep local backup'],
['value' => true, 'label' => 'Delete after S3 upload'],
]" />
</div>
</x-application.settings-section>
</form>
<x-application.settings-section title="S3 storage"
description="Choose where remote backups are stored and whether local copies are retained.">
<x-slot:actions>
@if (! $saveS3)
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" isHighlighted>Enable S3</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3">Disable S3</x-forms.button>
@endif
</x-slot:actions>
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="s3StorageIds" label="S3 storages" multiple live portal
helper="Each backup is uploaded to every selected storage." placeholder="Select S3 storages"
:options="$availableS3Storages->map(fn ($storage) => [
'value' => $storage->id,
'label' => $storage->name.($storage->is_usable ? '' : ' (unavailable)'),
])->all()" />
<x-forms.listbox id="disableLocalBackup" label="Local copy" portal :disabled="! $saveS3"
:options="[
['value' => false, 'label' => 'Keep local backup'],
['value' => true, 'label' => 'Delete after S3 upload'],
]" />
</div>
</x-application.settings-section>
@endif
@@ -1,4 +1,7 @@
<div wire:init="refreshBackupExecutions">
<div wire:init="refreshBackupExecutions" x-on:open-backup-download.window="window.location.href = $event.detail.url">
@if (count($pendingDownloads) > 0)
<div wire:poll.3s="checkDownloads" class="hidden"></div>
@endif
@isset($backup)
<section class="application-settings-section overflow-hidden">
<div class="application-settings-section-header">
@@ -197,7 +200,11 @@
@script
<script>
window.download_file = function(executionId) {
window.open('/download/backup/' + executionId, '_blank');
@if ($s3Downloads)
$wire.download_file(executionId);
@else
window.open('/download/backup/' + executionId, '_blank');
@endif
}
</script>
@endscript
@@ -5,6 +5,23 @@
<livewire:project.database.heading :database="$database" />
@can('manageBackups', $database)
@teleport('#resource-action-hud-slot')
<div class="order-first mr-1 hidden items-center gap-1 xl:flex">
@php($databaseRunning = str($database->status)->startsWith('running'))
@if ($backup->enabled)
<x-forms.button type="button" wire:click="toggleEnabled">Disable backup</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleEnabled" isHighlighted>Enable backup</x-forms.button>
@endif
<x-forms.button type="button" wire:click="backupNow" :disabled="! $databaseRunning"
:tooltip="! $databaseRunning ? 'The database must be running to start a backup.' : null">
Back up now
</x-forms.button>
</div>
@endteleport
@endcan
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-backup-sidebar context="database" :parameters="$parameters" :section="$section" />
@@ -15,7 +32,7 @@
@else
<livewire:project.database.backup-edit :backup="$backup" :available-s3-storages="$s3s"
:status="data_get($database, 'status')" :section="$section"
wire:key="database-backup-{{ $backup->uuid }}-{{ $section }}" />
:actions-in-header="true" wire:key="database-backup-{{ $backup->uuid }}-{{ $section }}" />
@endif
</div>
</div>
@@ -2,7 +2,7 @@
<x-slot:title>
{{ $environment->name }} > Canvas | Coolify
</x-slot>
<div data-environment-canvas data-title="Environment canvas" data-project="{{ $project->uuid }}" data-environment="{{ $environment->uuid }}"
<div data-environment-canvas data-project="{{ $project->uuid }}" data-environment="{{ $environment->uuid }}"
data-team="{{ $project->team_id }}" data-user="{{ auth()->id() }}"
data-new="{{ auth()->user()->can('createAnyResource') ? route('project.resource.create', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) : '' }}"
class="relative h-full" wire:ignore>
@@ -35,7 +35,7 @@
:aria-selected="resourceType === option.value"
@click="resourceType = option.value; close()">
<span x-text="option.label"></span>
<x-reicon name="check-circle" class="size-3.5 text-black dark:text-fg"
<x-reicon name="check" class="size-3.5 text-black dark:text-fg"
x-show="resourceType === option.value" />
</button>
</template>
@@ -71,7 +71,7 @@
:aria-selected="selectedCategory === ''"
@click="selectedCategory = ''; categorySearch = ''; categoryOpen = false">
<span>All categories</span>
<x-reicon name="check-circle" class="size-3.5 text-black dark:text-fg"
<x-reicon name="check" class="size-3.5 text-black dark:text-fg"
x-show="selectedCategory === ''" />
</button>
<template
@@ -81,7 +81,7 @@
:aria-selected="selectedCategory === category"
@click="selectedCategory = category; categorySearch = ''; categoryOpen = false">
<span class="truncate" x-text="category"></span>
<x-reicon name="check-circle" class="size-3.5 text-black dark:text-fg"
<x-reicon name="check" class="size-3.5 text-black dark:text-fg"
x-show="selectedCategory === category" />
</button>
</template>
@@ -95,9 +95,9 @@
{{-- A full page load: the canvas uses the minimal layout. --}}
<a href="{{ route('deploy-canvas', ['project' => data_get($parameters, 'project_uuid'), 'environment' => data_get($parameters, 'environment_uuid')]) }}"
x-show="search === ''"
class="mt-6 flex items-center gap-4 rounded-xl 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="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]">
<div
class="flex size-11 shrink-0 items-center justify-center rounded-xl border border-neutral-200 bg-neutral-50 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
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">
<x-reicon name="deploy-canvas" class="size-5" />
</div>
<div class="min-w-0 flex-1">
@@ -2,7 +2,7 @@
<x-slot:title>
{{ $project->name }} > Canvas | Coolify
</x-slot>
<div data-environment-canvas data-title="Project canvas" data-endpoint="/projects/{{ $project->uuid }}/canvas"
<div data-environment-canvas data-endpoint="/projects/{{ $project->uuid }}/canvas"
data-empty="No resources in this project yet"
data-view-only="Only team admins can move cards and edit links."
data-team="{{ $project->team_id }}" data-user="{{ auth()->id() }}"
@@ -2,7 +2,7 @@
<x-slot:title>
Projects > Canvas | Coolify
</x-slot>
<div data-environment-canvas data-title="Projects canvas" data-endpoint="/projects/canvas"
<div data-environment-canvas data-endpoint="/projects/canvas"
data-empty="No resources in your projects yet"
data-view-only="Only team admins can move cards and edit links."
data-team="{{ $teamId }}" data-user="{{ auth()->id() }}"
@@ -1,4 +1,7 @@
<div>
<div x-on:open-backup-download.window="window.location.href = $event.detail.url">
@if (count($pendingDownloads) > 0)
<div wire:poll.3s="checkDownloads" class="hidden"></div>
@endif
@if ($selectedExecution)
<x-modal-input title="Backup execution" wireOpen="executionModalOpen" :wireIgnore="false" isLarge>
<x-slot:content><span></span></x-slot:content>
@@ -60,7 +63,12 @@
<span>{{ $execution['started_at']->diffForHumans() }}</span>
<span>{{ $execution['size'] ? formatBytes($execution['size']) : '-' }}</span>
<span class="flex justify-end">
@if ($execution['download_url'])
@if ($execution['download_url'] && $s3Downloads)
<button type="button" wire:click.stop="downloadExecution('{{ $execution['uuid'] }}')"
class="icon-button shrink-0" title="Download backup" aria-label="Download backup">
<x-reicon name="upload" class="size-3.5 rotate-180" />
</button>
@elseif ($execution['download_url'])
<a href="{{ $execution['download_url'] }}" target="_blank" rel="noopener"
@click.stop class="icon-button shrink-0" title="Download backup"
aria-label="Download backup">
@@ -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 @@
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start"
<x-main-nav-menu :back-href="route('dashboard')" back-label="Dashboard" back-title="Back to the dashboard"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
@@ -71,6 +73,7 @@
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
@unless ($groupLabel === 'Danger Zone')
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
@@ -80,7 +83,8 @@
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@endunless
<div @class(['contents', 'xl:block' => $groupLabel === 'Danger Zone']) @unless ($groupLabel === 'Danger Zone') :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'" @endunless>
@foreach ($groupItems as $menuItem)
<a @class([
'menu-item',
@@ -111,7 +115,7 @@
</div>
@endforeach
</nav>
</aside>
</x-main-nav-menu>
<div class="min-w-0">
@if ($currentRoute === 'project.service.configuration')
@@ -132,25 +136,7 @@
</p>
</div>
<div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
<div
class="view-toggle">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
? 'control-selected'
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Table view" title="Table view">
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" x-on:click="setViewMode('grid')"
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid'
? 'control-selected'
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Grid view" title="Grid view">
<x-reicon name="grid" class="size-3.5" />
</button>
</div>
<x-canvas-view-toggle />
<a class="button" target="_blank" href="{{ $service->documentation() }}">
Documentation
<x-reicon name="external-link" class="size-4" />
@@ -218,8 +204,6 @@
</div>
@elseif ($currentRoute === 'project.service.scheduled-tasks.show')
<livewire:project.shared.scheduled-task.all :resource="$service" />
@elseif ($currentRoute === 'project.service.scheduled-tasks')
<livewire:project.shared.scheduled-task.show />
@elseif ($currentRoute === 'project.service.webhooks')
<livewire:project.shared.webhooks :resource="$service" />
@elseif ($currentRoute === 'project.service.resource-operations')
@@ -7,11 +7,11 @@
@if (! $embedded && $resourceType === 'database')
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" />
@elseif (! $embedded)
<aside class="application-settings-navigation min-w-0 xl:self-start">
<x-main-nav-menu :back-href="route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null])" back-label="Service">
<nav aria-label="Compose resource settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Compose resource</div>
<a class="menu-item" {{ wireNavigate() }}
<a class="menu-item" data-page-only {{ wireNavigate() }}
href="{{ route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null]) }}">
<x-reicon name="logout" class="menu-item-icon rotate-180" />
<span class="menu-item-label">Back to service</span>
@@ -22,7 +22,7 @@
<span class="menu-item-label">General</span>
</a>
</nav>
</aside>
</x-main-nav-menu>
@endif
<div class="min-w-0">
@if ($resourceType === 'application')
@@ -76,7 +76,7 @@
<x-backup-tabs context="service-schedule" :parameters="$parameters" section="general" />
@foreach (['general', 's3', 'retention', 'danger'] as $modalSection)
@foreach (['general', 'danger'] as $modalSection)
<div x-show="activeSection === '{{ $modalSection }}'" x-cloak>
@if ($selectedDatabaseBackup)
<livewire:project.database.backup-edit :backup="$selectedDatabaseBackup"
@@ -34,19 +34,17 @@
}));
},
}">
<div class="files-grid grid min-w-0 gap-8">
<div class="min-w-0">
@if ($type === 'application')
<x-application.configuration-sidebar :application="$resource"
current-route="project.application.files" />
@elseif ($type === 'service')
<x-service.configuration-sidebar :service="$resource"
current-route="project.service.files" />
@else
<x-database.configuration-sidebar :database="$resource"
current-route="project.database.files" />
@endif
</div>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
@if ($type === 'application')
<x-application.configuration-sidebar :application="$resource"
current-route="project.application.files" />
@elseif ($type === 'service')
<x-service.configuration-sidebar :service="$resource"
current-route="project.service.files" />
@else
<x-database.configuration-sidebar :database="$resource"
current-route="project.database.files" />
@endif
<div class="min-w-0">
<x-application.settings-section title="Files"
@@ -1,4 +1,13 @@
<div>
@if ($selectedTask)
<x-modal-input title="Edit scheduled task" :subtitle="$selectedTask->name" wireOpen="taskModalOpen" :wireIgnore="false" isLarge fixedHeight>
<x-slot:content><span></span></x-slot:content>
<livewire:project.shared.scheduled-task.show :resource="$resource" :task="$selectedTask"
wire:key="scheduled-task-modal-{{ $selectedTask->uuid }}" />
</x-modal-input>
@endif
<x-application.settings-section id="scheduled-tasks-section" title="Scheduled tasks"
helper="Run commands inside this resource automatically using a cron schedule." flush>
<x-slot:actions>
@@ -32,13 +41,10 @@
'running' => 'Running',
default => 'Not run yet',
};
$taskRoute = $resource->type() === 'application'
? 'project.application.scheduled-tasks'
: 'project.service.scheduled-tasks';
@endphp
<a class="group flex items-center gap-3 border-b border-neutral-200 px-4 py-3.5 transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.025]"
{{ wireNavigate() }}
href="{{ route($taskRoute, [...$parameters, 'task_uuid' => $task->uuid]) }}">
<div wire:key="scheduled-task-{{ $task->uuid }}" wire:click="openTask('{{ $task->uuid }}')"
wire:keydown.enter="openTask('{{ $task->uuid }}')" role="button" tabindex="0"
class="group flex cursor-pointer items-center gap-3 border-b border-neutral-200 px-4 py-3.5 transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 ring-1 ring-neutral-200 dark:bg-white/[0.05] dark:text-fg-dim dark:ring-white/[0.07]">
<x-reicon name="browser-terminal" class="size-[18px]" />
@@ -58,7 +64,7 @@
</p>
</div>
<x-status-badge :status="$statusLabel" :type="$statusType" />
</a>
</div>
@empty
<x-empty title="No scheduled tasks"
description="Create a task to run maintenance commands, scripts, or recurring jobs automatically."
@@ -1,10 +1,22 @@
<div class="flex flex-col gap-6">
<form wire:submit="submit" class="application-settings-form flex flex-col">
<div x-data="{ activeSection: 'general' }" class="flex min-w-0 flex-col gap-6">
<nav aria-label="Scheduled task sections"
class="flex min-w-0 flex-wrap gap-1 border-b border-neutral-200 pb-2 dark:border-white/[0.08]">
@foreach (['general' => 'General', 'executions' => 'Executions', ...(auth()->user()->can('update', $resource) ? ['danger' => 'Danger Zone'] : [])] as $sectionKey => $sectionLabel)
<button type="button" @click="activeSection = '{{ $sectionKey }}'"
:class="activeSection === '{{ $sectionKey }}'
? 'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25'
: 'text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg'"
class="inline-flex h-8 shrink-0 cursor-pointer items-center rounded-md px-3 text-[13px] font-medium transition-colors">
{{ $sectionLabel }}
</button>
@endforeach
</nav>
<form x-show="activeSection === 'general'" wire:submit="submit" class="application-settings-form flex flex-col">
<x-unsaved-bar action="submit" />
<x-application.settings-section id="task-configuration-section" title="Task configuration"
helper="Configure when this task runs and which command Coolify executes.">
<x-slot:actions>
<x-status-badge :status="$isEnabled ? 'Enabled' : 'Disabled'" :type="$isEnabled ? 'success' : 'neutral'" />
@if ($resource->isRunning())
@can('update', $resource)
<x-forms.button type="button" wire:click="executeNow">
@@ -16,13 +28,6 @@
<x-forms.button type="button" wire:click="toggleEnabled" :isHighlighted="!$isEnabled">
{{ $isEnabled ? 'Disable' : 'Enable' }}
</x-forms.button>
<x-modal-confirmation title="Delete scheduled task?" isErrorButton buttonTitle="Delete"
submitAction="delete({{ $task->id }})"
:actions="['The selected scheduled task will be permanently deleted.']"
confirmationText="{{ $task->name }}"
confirmationLabel="Enter the scheduled task name to confirm deletion"
shortConfirmationLabel="Task name" :confirmWithPassword="false"
step2ButtonText="Permanently delete" />
@endcan
</x-slot:actions>
@@ -47,8 +52,28 @@
</x-application.settings-section>
</form>
<x-application.settings-section id="task-executions-section" title="Recent executions"
<x-application.settings-section x-show="activeSection === 'executions'" x-cloak id="task-executions-section" title="Recent executions"
helper="Execution history refreshes automatically while a task is running." flush>
<livewire:project.shared.scheduled-task.executions :taskId="$task->id" />
</x-application.settings-section>
@can('update', $resource)
<div x-show="activeSection === 'danger'" x-cloak class="application-settings-form">
<x-application.settings-section title="Delete scheduled task"
description="Permanently remove this task and its execution history.">
<x-danger-zone title="This action cannot be undone.">
<p>The task stops running and cannot be restored.</p>
<x-slot:action>
<x-modal-confirmation title="Delete scheduled task?" isErrorButton buttonTitle="Delete task"
submitAction="delete({{ $task->id }})"
:actions="['The selected scheduled task will be permanently deleted.']"
confirmationText="{{ $task->name }}"
confirmationLabel="Enter the scheduled task name to confirm deletion"
shortConfirmationLabel="Task name" :confirmWithPassword="false"
step2ButtonText="Permanently delete" />
</x-slot:action>
</x-danger-zone>
</x-application.settings-section>
</div>
@endcan
</div>
@@ -1,9 +1,22 @@
<div>
@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')
<div x-on:open-backup-download.window="window.location.href = $event.detail.url">
@if (count($pendingDownloads) > 0)
<div wire:poll.3s="checkDownloads" class="hidden"></div>
@endif
@if ($actionsInHeader && $backup)
@can('update', $resource)
@teleport('#resource-action-hud-slot')
<div class="order-first mr-1 hidden items-center gap-1 xl:flex">
@if ($enabled)
<x-forms.button type="button" wire:click="toggleEnabled">Disable backup</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleEnabled" isHighlighted>Enable backup</x-forms.button>
@endif
<x-forms.button type="button" wire:click="backupNow">Back up now</x-forms.button>
</div>
@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
<script>
window.download_volume_backup_file = function(executionId) {
window.open('/download/volume-backup/' + executionId, '_blank');
@if ($s3Downloads)
$wire.downloadBackup(executionId);
@else
window.open('/download/volume-backup/' + executionId, '_blank');
@endif
}
</script>
@endscript
@@ -137,7 +137,7 @@
</span>
<span class="flex items-center justify-end gap-1">
@if ($execution->status === 'success' && ! $execution->local_storage_deleted)
@if ($execution->status === 'success' && $execution->isDownloadable())
<button type="button" class="icon-button shrink-0"
x-on:click="download_volume_backup_file('{{ $execution->id }}')"
title="Download backup" aria-label="Download backup">
@@ -1,10 +1,11 @@
<form wire:submit="save" class="application-settings-form">
<form wire:submit="save" class="application-settings-form flex flex-col gap-6">
<x-unsaved-bar action="save" />
<x-application.settings-section title="Backup schedule"
description="Choose when this storage is archived and how long each backup may run.">
<x-slot:actions>
<div class="flex items-center gap-2">
{{-- Below xl the header bar is hidden, so keep the buttons here. --}}
<div @class(['flex items-center gap-2', 'xl:hidden' => $actionsInHeader && $backup])>
@if (!$enabled)
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled" isHighlighted>Enable backup</x-forms.button>
@@ -49,4 +50,7 @@
helper="Notify through backup failure channels after this many days without an execution. Use 0 to disable." required />
</div>
</x-application.settings-section>
@include('livewire.project.shared.storages.volume-backups.s3')
@include('livewire.project.shared.storages.volume-backups.retention')
</form>
@@ -1,35 +1,31 @@
<form wire:submit="save" class="application-settings-form">
<x-unsaved-bar action="save" />
<x-application.settings-section title="Retention"
description="The first reached limit removes the oldest archive. Use 0 for unlimited retention.">
<div>
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Local backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="retentionAmountLocally" type="number"
min="0" canGate="update" :canResource="$resource"
helper="Maximum number of recent local backups." required />
<x-forms.input label="Days to keep" id="retentionDaysLocally" type="number"
min="0" canGate="update" :canResource="$resource"
helper="Remove local backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageLocally" type="number"
min="0" step="any" canGate="update" :canResource="$resource"
helper="Remove the oldest local backups after this total size is reached." required />
</div>
<x-application.settings-section title="Retention"
description="The first reached limit removes the oldest archive. Use 0 for unlimited retention.">
<div>
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Local backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="retentionAmountLocally" type="number"
min="0" canGate="update" :canResource="$resource"
helper="Maximum number of recent local backups." required />
<x-forms.input label="Days to keep" id="retentionDaysLocally" type="number"
min="0" canGate="update" :canResource="$resource"
helper="Remove local backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageLocally" type="number"
min="0" step="any" canGate="update" :canResource="$resource"
helper="Remove the oldest local backups after this total size is reached." required />
</div>
</div>
<div class="mt-6 border-t border-neutral-200 pt-6 dark:border-white/[0.06]">
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">S3 backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="retentionAmountS3" type="number" min="0"
canGate="update" :canResource="$resource" helper="Maximum number of recent S3 backups." required />
<x-forms.input label="Days to keep" id="retentionDaysS3" type="number" min="0"
canGate="update" :canResource="$resource"
helper="Remove S3 backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageS3" type="number"
min="0" step="any" canGate="update" :canResource="$resource"
helper="Remove the oldest S3 backups after this total size is reached." required />
</div>
<div class="mt-6 border-t border-neutral-200 pt-6 dark:border-white/[0.06]">
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">S3 backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="retentionAmountS3" type="number" min="0"
canGate="update" :canResource="$resource" helper="Maximum number of recent S3 backups." required />
<x-forms.input label="Days to keep" id="retentionDaysS3" type="number" min="0"
canGate="update" :canResource="$resource"
helper="Remove S3 backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageS3" type="number"
min="0" step="any" canGate="update" :canResource="$resource"
helper="Remove the oldest S3 backups after this total size is reached." required />
</div>
</x-application.settings-section>
</form>
</div>
</x-application.settings-section>
@@ -10,37 +10,33 @@
</x-empty>
</x-application.settings-section>
@else
<form wire:submit="save">
<x-unsaved-bar action="save" />
<x-application.settings-section title="S3 storage"
description="Choose where remote copies are stored and whether local archives are retained.">
<x-slot:actions>
@if (! $saveToS3)
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" isHighlighted canGate="update" :canResource="$resource">
Enable S3
</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" canGate="update" :canResource="$resource">
Disable S3
</x-forms.button>
@endif
</x-slot:actions>
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox canGate="update" :canResource="$resource" id="s3StorageIds" label="S3 storages" multiple live portal
helper="Each backup is uploaded to every selected storage." placeholder="Select S3 storages"
:options="$availableS3Storages->map(fn ($storage) => [
'value' => $storage->id,
'label' => $storage->name.($storage->is_usable ? '' : ' (unavailable)'),
])->all()" />
<x-forms.listbox canGate="update" :canResource="$resource" id="disableLocalBackup" label="Local copy"
:disabled="! $saveToS3 || ! auth()->user()?->can('update', $resource)" :options="[
['value' => false, 'label' => 'Keep local backup'],
['value' => true, 'label' => 'Delete after S3 upload'],
]" />
</div>
</x-application.settings-section>
</form>
<x-application.settings-section title="S3 storage"
description="Choose where remote copies are stored and whether local archives are retained.">
<x-slot:actions>
@if (! $saveToS3)
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" isHighlighted canGate="update" :canResource="$resource">
Enable S3
</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" canGate="update" :canResource="$resource">
Disable S3
</x-forms.button>
@endif
</x-slot:actions>
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox canGate="update" :canResource="$resource" id="s3StorageIds" label="S3 storages" multiple live portal
helper="Each backup is uploaded to every selected storage." placeholder="Select S3 storages"
:options="$availableS3Storages->map(fn ($storage) => [
'value' => $storage->id,
'label' => $storage->name.($storage->is_usable ? '' : ' (unavailable)'),
])->all()" />
<x-forms.listbox canGate="update" :canResource="$resource" id="disableLocalBackup" label="Local copy"
:disabled="! $saveToS3 || ! auth()->user()?->can('update', $resource)" :options="[
['value' => false, 'label' => 'Keep local backup'],
['value' => true, 'label' => 'Delete after S3 upload'],
]" />
</div>
</x-application.settings-section>
@endif
@@ -5,12 +5,12 @@
@if ($server)
{{-- Status, proxy actions and the server name dock in the top bar. --}}
<livewire:server.navbar :server="$server" />
{{-- Fixed rail on xl, hidden below xl (see .app-main-full .application-settings-navigation in app.css). --}}
{{-- Its menu shows in the main sidebar on xl (components/server/sidebar.blade.php); below xl a canvas has no room for it. --}}
<x-server.sidebar :server="$server" activeMenu="resources" />
@endif
<div data-environment-canvas data-title="{{ $server ? 'Server canvas' : 'Servers canvas' }}"
<div data-environment-canvas
data-endpoint="{{ $server ? '/servers/'.$server->uuid.'/canvas' : '/servers/canvas' }}"
data-team="{{ $teamId }}" data-user="{{ auth()->id() }}" @if ($server) data-toolbar-inline @endif
data-team="{{ $teamId }}" data-user="{{ auth()->id() }}"
class="relative min-h-0 flex-1" wire:ignore>
@if ($server)
<x-canvas-view-toggle data-ec-views on-canvas :views="['list' => ['List view', 'unordered-list']]"
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more