mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 09:34:36 +08:00
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:
1 parent
ad526a4c32
commit
fa1895c99a
134 files changed
+4255
-1594
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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" && '
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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>
|
||||
+28
-32
@@ -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
Reference in new issue
Block a user