mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-18 10:23:22 +00:00
feat(ui): unify resource headings with responsive action menus
Add responsive overflow handling and dedicated action menus for application, service, database, and server headings, while improving upgrade and email settings flows and returning 404s for missing deployment environments.
This commit is contained in:
@@ -154,6 +154,21 @@ topbar instead of repeating its name or status summary in layer 2. Mobile
|
||||
resource navigation may repeat this context because the desktop global topbar
|
||||
is hidden there.
|
||||
|
||||
Desktop resource lifecycle actions dock in `#resource-action-hud-slot` and
|
||||
use `<x-resource-heading-overflow>`. Show primary actions (Deploy, Redeploy,
|
||||
Restart, Stop) as sibling header buttons. Collapse that group into an Actions
|
||||
dropdown only when the remaining top-bar width cannot fit them (breadcrumb
|
||||
keeps a 200px floor). Infrequent operations live in a separate Advanced
|
||||
dropdown with the grid icon: force restart / force deploy / force cleanup
|
||||
on services, and Traefik dashboard / refresh proxy status on servers. Place
|
||||
Advanced immediately after Links, or first in the action cluster when there
|
||||
is no Links control. Application Deploy is a dropdown with Deploy and
|
||||
Deploy (without cache). A running service Restart control is a dropdown with
|
||||
Restart current version and Pull latest and restart. Mobile
|
||||
headings keep a full-width Actions dropdown because the desktop HUD is hidden
|
||||
below `xl`. Do not hide primary actions behind a menu on a wide desktop. Links
|
||||
stay a separate dropdown because the URL list is unbounded.
|
||||
|
||||
Only add layer-2 tabs when they represent real sibling routes inside one
|
||||
context. Never repeat main-sidebar destinations such as Dashboard, Projects,
|
||||
Terminal, Servers, Sources, Destinations, or Storage as a second tab row. A
|
||||
|
||||
@@ -302,7 +302,7 @@ class Email extends Component
|
||||
$this->resetErrorBag();
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => 'required|string',
|
||||
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
|
||||
@@ -65,10 +65,11 @@ class Index extends Component
|
||||
if (! $project) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first()->load(['applications']);
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first();
|
||||
if (! $environment) {
|
||||
return redirect()->route('dashboard');
|
||||
abort(404);
|
||||
}
|
||||
$environment->load(['applications']);
|
||||
$application = $environment->applications->where('uuid', request()->route('application_uuid'))->first();
|
||||
if (! $application) {
|
||||
return redirect()->route('dashboard');
|
||||
|
||||
@@ -39,10 +39,11 @@ class Show extends Component
|
||||
if (! $project) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first()->load(['applications']);
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first();
|
||||
if (! $environment) {
|
||||
return redirect()->route('dashboard');
|
||||
abort(404);
|
||||
}
|
||||
$environment->load(['applications']);
|
||||
$application = $environment->applications->where('uuid', request()->route('application_uuid'))->first();
|
||||
if (! $application) {
|
||||
return redirect()->route('dashboard');
|
||||
|
||||
@@ -26,10 +26,11 @@ class Execution extends Component
|
||||
if (! $project) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first()->load(['applications']);
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first();
|
||||
if (! $environment) {
|
||||
return redirect()->route('dashboard');
|
||||
abort(404);
|
||||
}
|
||||
$environment->load(['applications']);
|
||||
$database = $environment->databases()->where('uuid', request()->route('database_uuid'))->first();
|
||||
if (! $database) {
|
||||
return redirect()->route('dashboard');
|
||||
|
||||
@@ -14,10 +14,11 @@ class Index extends Component
|
||||
if (! $project) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first()->load(['applications']);
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first();
|
||||
if (! $environment) {
|
||||
return redirect()->route('dashboard');
|
||||
abort(404);
|
||||
}
|
||||
$environment->load(['applications']);
|
||||
$database = $environment->databases()->where('uuid', request()->route('database_uuid'))->first();
|
||||
if (! $database) {
|
||||
return redirect()->route('dashboard');
|
||||
|
||||
@@ -203,7 +203,7 @@ class SettingsEmail extends Component
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => 'required|string',
|
||||
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"phpstan/phpstan": "^2.2.8",
|
||||
"rector/rector": "^2.6.1",
|
||||
"serversideup/spin": "^3.3.0",
|
||||
"spatie/laravel-ignition": "^2.12.0",
|
||||
"symfony/http-client": "^7.4.16"
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
|
||||
Generated
+1
-386
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "37ccd1e33f9eebf1d5b4a88b1113e0b3",
|
||||
"content-hash": "9cfa94749243cbf2879e01939d7f86cb",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -16541,391 +16541,6 @@
|
||||
],
|
||||
"time": "2026-07-16T20:06:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/backtrace",
|
||||
"version": "1.8.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/backtrace.git",
|
||||
"reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc",
|
||||
"reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.3 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-json": "*",
|
||||
"laravel/serializable-closure": "^1.3 || ^2.0",
|
||||
"phpunit/phpunit": "^9.3 || ^11.4.3",
|
||||
"spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6",
|
||||
"symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Backtrace\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van de Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A better backtrace",
|
||||
"homepage": "https://github.com/spatie/backtrace",
|
||||
"keywords": [
|
||||
"Backtrace",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/backtrace/issues",
|
||||
"source": "https://github.com/spatie/backtrace/tree/1.8.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/spatie",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-11T13:48:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/error-solutions",
|
||||
"version": "1.1.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/error-solutions.git",
|
||||
"reference": "e495d7178ca524f2dd0fe6a1d99a1e608e1c9936"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/error-solutions/zipball/e495d7178ca524f2dd0fe6a1d99a1e608e1c9936",
|
||||
"reference": "e495d7178ca524f2dd0fe6a1d99a1e608e1c9936",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"illuminate/broadcasting": "^10.0|^11.0|^12.0",
|
||||
"illuminate/cache": "^10.0|^11.0|^12.0",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||
"livewire/livewire": "^2.11|^3.5.20",
|
||||
"openai-php/client": "^0.10.1",
|
||||
"orchestra/testbench": "8.22.3|^9.0|^10.0",
|
||||
"pestphp/pest": "^2.20|^3.0",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"psr/simple-cache": "^3.0",
|
||||
"psr/simple-cache-implementation": "^3.0",
|
||||
"spatie/ray": "^1.28",
|
||||
"symfony/cache": "^5.4|^6.0|^7.0",
|
||||
"symfony/process": "^5.4|^6.0|^7.0",
|
||||
"vlucas/phpdotenv": "^5.5"
|
||||
},
|
||||
"suggest": {
|
||||
"openai-php/client": "Require get solutions from OpenAI",
|
||||
"simple-cache-implementation": "To cache solutions from OpenAI"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Ignition\\": "legacy/ignition",
|
||||
"Spatie\\ErrorSolutions\\": "src",
|
||||
"Spatie\\LaravelIgnition\\": "legacy/laravel-ignition"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ruben Van Assche",
|
||||
"email": "ruben@spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "This is my package error-solutions",
|
||||
"homepage": "https://github.com/spatie/error-solutions",
|
||||
"keywords": [
|
||||
"error-solutions",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/error-solutions/issues",
|
||||
"source": "https://github.com/spatie/error-solutions/tree/1.1.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-02-14T12:29:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/flare-client-php",
|
||||
"version": "1.11.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/flare-client-php.git",
|
||||
"reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/flare-client-php/zipball/53f41b08a27cc039e1a8ed2be9a202e924f31bad",
|
||||
"reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"php": "^8.0",
|
||||
"spatie/backtrace": "^1.6.1",
|
||||
"symfony/http-foundation": "^5.2|^6.0|^7.0|^8.0",
|
||||
"symfony/mime": "^5.2|^6.0|^7.0|^8.0",
|
||||
"symfony/process": "^5.2|^6.0|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^5.2|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dms/phpunit-arraysubset-asserts": "^0.5.0",
|
||||
"pestphp/pest": "^1.20|^2.0",
|
||||
"phpstan/extension-installer": "^1.1",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"spatie/pest-plugin-snapshots": "^1.0|^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "1.3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Spatie\\FlareClient\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Send PHP errors to Flare",
|
||||
"homepage": "https://github.com/spatie/flare-client-php",
|
||||
"keywords": [
|
||||
"exception",
|
||||
"flare",
|
||||
"reporting",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/flare-client-php/issues",
|
||||
"source": "https://github.com/spatie/flare-client-php/tree/1.11.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-15T09:31:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/ignition",
|
||||
"version": "1.16.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/ignition.git",
|
||||
"reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/ignition/zipball/b59385bb7aa24dae81bcc15850ebecfda7b40838",
|
||||
"reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"php": "^8.0",
|
||||
"spatie/backtrace": "^1.7.1",
|
||||
"spatie/error-solutions": "^1.1.2",
|
||||
"spatie/flare-client-php": "^1.9",
|
||||
"symfony/console": "^5.4.42|^6.0|^7.0|^8.0",
|
||||
"symfony/http-foundation": "^5.4.42|^6.0|^7.0|^8.0",
|
||||
"symfony/mime": "^5.4.42|^6.0|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^5.4.42|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"illuminate/cache": "^9.52|^10.0|^11.0|^12.0|^13.0",
|
||||
"mockery/mockery": "^1.4",
|
||||
"pestphp/pest": "^1.20|^2.0|^3.0",
|
||||
"phpstan/extension-installer": "^1.1",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"psr/simple-cache-implementation": "*",
|
||||
"symfony/cache": "^5.4.38|^6.0|^7.0|^8.0",
|
||||
"symfony/process": "^5.4.35|^6.0|^7.0|^8.0",
|
||||
"vlucas/phpdotenv": "^5.5"
|
||||
},
|
||||
"suggest": {
|
||||
"openai-php/client": "Require get solutions from OpenAI",
|
||||
"simple-cache-implementation": "To cache solutions from OpenAI"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "1.5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Ignition\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Spatie",
|
||||
"email": "info@spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A beautiful error page for PHP applications.",
|
||||
"homepage": "https://flareapp.io/ignition",
|
||||
"keywords": [
|
||||
"error",
|
||||
"flare",
|
||||
"laravel",
|
||||
"page"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://flareapp.io/docs/ignition-for-laravel/introduction",
|
||||
"forum": "https://twitter.com/flareappio",
|
||||
"issues": "https://github.com/spatie/ignition/issues",
|
||||
"source": "https://github.com/spatie/ignition"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-17T10:51:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-ignition",
|
||||
"version": "2.12.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/laravel-ignition.git",
|
||||
"reference": "45b3b6e1e73fc161cba2149972698644b99594ee"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/45b3b6e1e73fc161cba2149972698644b99594ee",
|
||||
"reference": "45b3b6e1e73fc161cba2149972698644b99594ee",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||
"nesbot/carbon": "^2.72|^3.0",
|
||||
"php": "^8.2",
|
||||
"spatie/ignition": "^1.16",
|
||||
"symfony/console": "^7.4|^8.0",
|
||||
"symfony/var-dumper": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"livewire/livewire": "^3.7.0|^4.0|dev-josh/v3-laravel-13-support",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"openai-php/client": "^0.10.3|^0.19",
|
||||
"orchestra/testbench": "^v9.16.0|^10.6|^11.0",
|
||||
"pestphp/pest": "^3.7|^4.0",
|
||||
"phpstan/extension-installer": "^1.4.3",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.3",
|
||||
"phpstan/phpstan-phpunit": "^2.0.8",
|
||||
"vlucas/phpdotenv": "^5.6.2"
|
||||
},
|
||||
"suggest": {
|
||||
"openai-php/client": "Require get solutions from OpenAI",
|
||||
"psr/simple-cache-implementation": "Needed to cache solutions from OpenAI"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Flare": "Spatie\\LaravelIgnition\\Facades\\Flare"
|
||||
},
|
||||
"providers": [
|
||||
"Spatie\\LaravelIgnition\\IgnitionServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Spatie\\LaravelIgnition\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Spatie",
|
||||
"email": "info@spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A beautiful error page for Laravel applications.",
|
||||
"homepage": "https://flareapp.io/ignition",
|
||||
"keywords": [
|
||||
"error",
|
||||
"flare",
|
||||
"laravel",
|
||||
"page"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://flareapp.io/docs/ignition-for-laravel/introduction",
|
||||
"forum": "https://twitter.com/flareappio",
|
||||
"issues": "https://github.com/spatie/laravel-ignition/issues",
|
||||
"source": "https://github.com/spatie/laravel-ignition"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-17T12:20:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "staabm/side-effects-detector",
|
||||
"version": "1.0.5",
|
||||
|
||||
@@ -398,6 +398,12 @@ html[data-theme="custom"] .animate-spin {
|
||||
color: var(--theme-bright-color) !important;
|
||||
}
|
||||
|
||||
/* Opt out of the brand spinner when the surrounding surface is a selected/neutral control. */
|
||||
.dark .animate-spin.spinner-current,
|
||||
html[data-theme="custom"] .animate-spin.spinner-current {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
html[data-theme="custom"] #nprogress .bar {
|
||||
background: var(--theme-bright-color) !important;
|
||||
}
|
||||
@@ -1710,6 +1716,58 @@ html[data-theme="custom"] textarea:disabled {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.resource-heading-overflow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resource-heading-overflow-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.resource-heading-overflow-items.is-measuring {
|
||||
display: flex !important;
|
||||
visibility: hidden !important;
|
||||
position: absolute !important;
|
||||
inset: auto auto 0 0 !important;
|
||||
flex-direction: row !important;
|
||||
width: max-content !important;
|
||||
height: auto !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
pointer-events: none !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.resource-heading-overflow-separator {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 0.25rem 0.25rem;
|
||||
background: color-mix(in srgb, var(--coollabs-line) 80%, transparent);
|
||||
}
|
||||
|
||||
.resource-heading-overflow.is-collapsed .resource-heading-overflow-separator {
|
||||
width: auto;
|
||||
height: 1px;
|
||||
align-self: auto;
|
||||
margin: 0.25rem 0.375rem;
|
||||
}
|
||||
|
||||
.resource-heading-overflow.is-collapsed .resource-heading-overflow-items > .button,
|
||||
.resource-heading-overflow.is-collapsed .resource-heading-overflow-items > a.button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
height: auto;
|
||||
min-height: 2rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
}
|
||||
|
||||
/* Heading action group: buttons + dropdown triggers styled like the tabs */
|
||||
.application-heading-actions .button,
|
||||
.application-heading-actions .app-tab,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{{-- Application Advanced is unused. Deploy (without cache) lives on the Deploy
|
||||
dropdown. Keep this file so Advanced menus stay on the name="grid" icon. --}}
|
||||
@props(['application'])
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
@props(['application'])
|
||||
|
||||
@php
|
||||
$canDeploy = auth()->user()->can('deploy', $application);
|
||||
$isExited = str($application->status)->startsWith('exited');
|
||||
$isSwarm = $application->destination->server->isSwarm();
|
||||
$isCompose = $application->build_pack === 'dockercompose';
|
||||
$withoutCacheAction = $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)';
|
||||
@endphp
|
||||
|
||||
@if ($isExited)
|
||||
@if ($isSwarm)
|
||||
<button type="button" class="button" @disabled(! $canDeploy) wire:click="deploy">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@else
|
||||
<div class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu" @disabled(! $canDeploy)>
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy) wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy) wire:click="{{ $withoutCacheAction }}"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Deploy (without cache)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@elseif (! $isSwarm)
|
||||
<div class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu" @disabled(! $canDeploy)>
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Redeploy
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy) wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy) wire:click="{{ $withoutCacheAction }}"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Deploy (without cache)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@elseif (! $isCompose)
|
||||
<button type="button" class="button" @disabled(! $canDeploy) wire:click="deploy">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Update Service
|
||||
</button>
|
||||
@endif
|
||||
@@ -0,0 +1,149 @@
|
||||
{{--
|
||||
Resource header actions: render inline in the top bar, then collapse into
|
||||
an Actions dropdown when the remaining header width cannot fit them.
|
||||
--}}
|
||||
@props([
|
||||
'id' => null,
|
||||
'label' => 'Actions',
|
||||
])
|
||||
|
||||
<div
|
||||
@if (filled($id)) id="{{ $id }}" @endif
|
||||
{{ $attributes->class('resource-heading-overflow relative shrink-0') }}
|
||||
data-resource-heading-overflow
|
||||
x-data="{
|
||||
collapsed: false,
|
||||
open: false,
|
||||
ro: null,
|
||||
onNavigated: null,
|
||||
update() {
|
||||
const needed = this.measureInlineWidth()
|
||||
const available = this.availableWidth()
|
||||
const next = needed > 0 && needed > available
|
||||
if (next === this.collapsed) {
|
||||
return
|
||||
}
|
||||
this.collapsed = next
|
||||
if (!next) {
|
||||
this.open = false
|
||||
}
|
||||
},
|
||||
hudSlot() {
|
||||
return this.$el.closest('#resource-action-hud-slot')
|
||||
?? document.getElementById('resource-action-hud-slot')
|
||||
},
|
||||
headerRow() {
|
||||
return this.hudSlot()?.parentElement ?? null
|
||||
},
|
||||
measureInlineWidth() {
|
||||
const items = this.$refs.items
|
||||
if (!items) {
|
||||
return 0
|
||||
}
|
||||
items.classList.add('is-measuring')
|
||||
const width = items.scrollWidth
|
||||
items.classList.remove('is-measuring')
|
||||
return width
|
||||
},
|
||||
availableWidth() {
|
||||
const row = this.headerRow()
|
||||
const hud = this.hudSlot()
|
||||
if (!row || !hud) {
|
||||
return Infinity
|
||||
}
|
||||
|
||||
let reserved = 0
|
||||
for (const child of row.children) {
|
||||
if (child === hud) {
|
||||
continue
|
||||
}
|
||||
if (child.classList.contains('flex-1') || child.querySelector(':scope .flex-1')) {
|
||||
reserved += 200
|
||||
continue
|
||||
}
|
||||
reserved += child.getBoundingClientRect().width
|
||||
}
|
||||
|
||||
let hudSiblings = 0
|
||||
const cluster = this.$el.parentElement
|
||||
if (cluster) {
|
||||
for (const child of cluster.children) {
|
||||
if (child === this.$el) {
|
||||
continue
|
||||
}
|
||||
hudSiblings += child.getBoundingClientRect().width
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, row.clientWidth - reserved - hudSiblings - 16)
|
||||
},
|
||||
observe() {
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (!this.ro) {
|
||||
this.ro = new ResizeObserver(() => this.update())
|
||||
}
|
||||
const row = this.headerRow()
|
||||
if (row) {
|
||||
this.ro.observe(row)
|
||||
}
|
||||
this.ro.observe(this.$el)
|
||||
},
|
||||
init() {
|
||||
this.onNavigated = () => this.$nextTick(() => {
|
||||
this.observe()
|
||||
this.update()
|
||||
})
|
||||
document.addEventListener('livewire:navigated', this.onNavigated)
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.observe()
|
||||
this.update()
|
||||
requestAnimationFrame(() => {
|
||||
this.observe()
|
||||
this.update()
|
||||
})
|
||||
})
|
||||
},
|
||||
destroy() {
|
||||
this.ro?.disconnect()
|
||||
if (this.onNavigated) {
|
||||
document.removeEventListener('livewire:navigated', this.onNavigated)
|
||||
}
|
||||
},
|
||||
}"
|
||||
x-init="init()"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
@resize.window.debounce.50ms="update()"
|
||||
:class="{ 'is-collapsed': collapsed }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button resource-heading-overflow-trigger"
|
||||
x-show="collapsed"
|
||||
x-cloak
|
||||
@click="open = !open"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
{{ $label }}
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-ref="items"
|
||||
class="resource-heading-overflow-items"
|
||||
x-show="!collapsed || open"
|
||||
:class="collapsed && 'listbox-panel top-full! right-0! left-auto! mt-1! min-w-52!'"
|
||||
:role="collapsed ? 'menu' : null"
|
||||
:aria-hidden="collapsed && !open"
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
@props([
|
||||
'serverIp',
|
||||
'traefikDashboardAvailable' => false,
|
||||
])
|
||||
|
||||
<div class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu">
|
||||
<x-reicon name="grid" class="size-3.5 opacity-70" />
|
||||
Advanced
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a class="listbox-option justify-start! gap-2.5!" target="_blank"
|
||||
href="http://{{ $serverIp }}:8080" @click="open = false" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="external-link" class="size-3! opacity-70" />
|
||||
</span>
|
||||
Traefik Dashboard
|
||||
</a>
|
||||
@endif
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="checkProxyStatus" wire:loading.attr="disabled"
|
||||
@click="open = false" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Refresh Proxy Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
@php
|
||||
$status = str($service->status ?? '');
|
||||
$canDeploy = auth()->user()->can('deploy', $service);
|
||||
$canStop = auth()->user()->can('stop', $service);
|
||||
$hasAdvancedItems = ! $status->contains('running');
|
||||
@endphp
|
||||
|
||||
@if ($hasAdvancedItems)
|
||||
<div class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu">
|
||||
<x-reicon name="grid" class="size-3.5 opacity-70" />
|
||||
Advanced
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-64! min-w-0!" role="menu">
|
||||
@if ($status->contains('degraded'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy)
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy)
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Deploy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canStop)
|
||||
@click="$wire.dispatch('cleanupEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="trash" class="size-3.5 opacity-70" />
|
||||
Force Cleanup Containers
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,50 @@
|
||||
@props(['service'])
|
||||
|
||||
@php
|
||||
$status = str($service->status ?? '');
|
||||
$canDeploy = auth()->user()->can('deploy', $service);
|
||||
$isRunning = $status->contains('running');
|
||||
$isDegraded = $status->contains('degraded');
|
||||
@endphp
|
||||
|
||||
@if ($isRunning || $isDegraded)
|
||||
@if ($isRunning)
|
||||
<div class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu" @disabled(! $canDeploy)>
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy)
|
||||
@click="open = false; document.getElementById('service-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart current version
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy)
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Pull latest and restart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<button type="button" class="button"
|
||||
@disabled(! $canDeploy)
|
||||
@click="document.getElementById('service-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
@@ -25,14 +25,14 @@
|
||||
<div
|
||||
class="flex min-h-10 items-center justify-center gap-2 border-r border-neutral-200 px-2 text-[11px] font-medium last:border-r-0 dark:border-white/[0.08] sm:px-3"
|
||||
:class="{
|
||||
'bg-coollabs/[0.07] text-coollabs dark:bg-warning/[0.09] dark:text-warning': currentStep === {{ $stepNumber }},
|
||||
'bg-neutral-100 text-neutral-900 dark:bg-white/[0.08] dark:text-fg': currentStep === {{ $stepNumber }},
|
||||
'text-emerald-600 dark:text-emerald-400': currentStep > {{ $stepNumber }},
|
||||
'text-neutral-500 dark:text-fg-dim': currentStep < {{ $stepNumber }}
|
||||
}">
|
||||
<span
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold"
|
||||
:class="{
|
||||
'border-coollabs/25 bg-coollabs/10 dark:border-warning/25 dark:bg-warning/15': currentStep === {{ $stepNumber }},
|
||||
'border-neutral-300 bg-white dark:border-white/[0.16] dark:bg-white/[0.1]': currentStep === {{ $stepNumber }},
|
||||
'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400': currentStep > {{ $stepNumber }},
|
||||
'border-neutral-200 dark:border-white/[0.1]': currentStep < {{ $stepNumber }}
|
||||
}">
|
||||
@@ -40,7 +40,7 @@
|
||||
<x-reicon name="check-circle" class="size-3" />
|
||||
</template>
|
||||
<template x-if="currentStep === {{ $stepNumber }}">
|
||||
<svg class="loading-indicator size-3 animate-spin" xmlns="http://www.w3.org/2000/svg"
|
||||
<svg class="spinner-current size-3 animate-spin text-current" 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>
|
||||
|
||||
@@ -118,22 +118,18 @@
|
||||
</div>
|
||||
|
||||
<div class="application-settings-form">
|
||||
<x-application.settings-section title="Resend"
|
||||
description="Use Resend as an alternative email delivery provider.">
|
||||
<x-application.settings-section title="Resend">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="w-full sm:w-72">
|
||||
<x-forms.listbox id="resendEnabled" label="Resend delivery"
|
||||
onChange="submitResend"
|
||||
:disabled="!auth()->user()->can('update', $settings)" :options="[
|
||||
['value' => true, 'label' => 'Enabled'],
|
||||
['value' => false, 'label' => 'Disabled'],
|
||||
]" />
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.listbox id="resendEnabled" label="Resend delivery"
|
||||
onChange="submitResend"
|
||||
:disabled="!auth()->user()->can('update', $settings)" :options="[
|
||||
['value' => true, 'label' => 'Enabled'],
|
||||
['value' => false, 'label' => 'Disabled'],
|
||||
]" />
|
||||
@can('update', $settings)
|
||||
<x-forms.input canGate="update" :canResource="$settings" required type="password"
|
||||
id="resendApiKey" placeholder="API key" label="API key" />
|
||||
<x-forms.input canGate="update" :canResource="$settings" :required="$resendEnabled"
|
||||
type="password" id="resendApiKey" placeholder="API key" label="API key"
|
||||
autocomplete="new-password" />
|
||||
@else
|
||||
<x-forms.input disabled label="API key" value="Hidden (only admins can view)" />
|
||||
@endcan
|
||||
|
||||
@@ -58,13 +58,13 @@
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Redeploy
|
||||
Deploy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Redeploy
|
||||
Deploy
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
@@ -135,13 +135,13 @@
|
||||
wire:click="{{ $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)' }}"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force deploy without cache
|
||||
Deploy (without cache)
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force deploy without cache
|
||||
Deploy (without cache)
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
@@ -186,96 +186,37 @@
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-applications.links :application="$application" />
|
||||
</div>
|
||||
<div id="application-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
@if (str($application->status)->startsWith('exited'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $application))
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@else
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Redeploy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Redeploy
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
@if ($application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Update Service
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Update Service
|
||||
</button>
|
||||
@endcan
|
||||
@else
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
@endif
|
||||
<x-applications.deploy :application="$application" />
|
||||
<x-resource-heading-overflow id="application-desktop-actions">
|
||||
@if (! str($application->status)->startsWith('exited'))
|
||||
@if ($application->build_pack !== 'dockercompose' && ! $application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
<button type="button" class="button"
|
||||
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
<button type="button" class="button" disabled>
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300" role="separator"></div>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $application))
|
||||
wire:click="{{ $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)' }}"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force deploy without cache
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="button"
|
||||
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<button type="button" class="button" disabled>
|
||||
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
</x-resource-heading-overflow>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,38 +138,28 @@
|
||||
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
|
||||
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
|
||||
@if ($database->destination->server->isFunctional())
|
||||
@if (! $databaseStatus->startsWith('exited'))
|
||||
<div id="database-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
<x-resource-heading-overflow id="database-desktop-actions">
|
||||
@if (! $databaseStatus->startsWith('exited'))
|
||||
<button type="button" class="button"
|
||||
@disabled(!auth()->user()->can('manage', $database))
|
||||
@click="open = false; document.getElementById('database-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="menu">
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('manage', $database))
|
||||
@click="open = false; document.getElementById('database-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('manage', $database))
|
||||
@click="open = false; document.getElementById('database-stop-trigger')?.click()">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.button canGate="manage" :canResource="$database"
|
||||
@click="$wire.dispatch('startEvent')">
|
||||
<x-reicon name="play-circle" class="size-4 opacity-70" />
|
||||
Start
|
||||
</x-forms.button>
|
||||
@endif
|
||||
<button type="button" class="button"
|
||||
@disabled(!auth()->user()->can('manage', $database))
|
||||
@click="open = false; document.getElementById('database-stop-trigger')?.click()">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<x-forms.button canGate="manage" :canResource="$database"
|
||||
@click="$wire.dispatch('startEvent')">
|
||||
<x-reicon name="play-circle" class="size-4 opacity-70" />
|
||||
Start
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</x-resource-heading-overflow>
|
||||
@else
|
||||
<x-status-badge status="Server unavailable" type="error" />
|
||||
@endif
|
||||
|
||||
@@ -94,15 +94,32 @@
|
||||
@click="open = false; document.getElementById('service-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
Restart current version
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
Restart current version
|
||||
</button>
|
||||
@endcan
|
||||
@if ($serviceStatus->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Pull latest and restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Restart
|
||||
</button>
|
||||
@endif
|
||||
@can('stop', $service)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()"
|
||||
@@ -117,23 +134,6 @@
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
@if ($serviceStatus->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Pull Latest Images & Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Restart
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
@can('deploy', $service)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@@ -184,68 +184,25 @@
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-services.links :service="$service" />
|
||||
</div>
|
||||
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
<x-services.advanced :service="$service" />
|
||||
<x-services.restart :service="$service" />
|
||||
<x-resource-heading-overflow id="service-desktop-actions">
|
||||
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
|
||||
<button type="button" class="button"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-64! min-w-0!" role="menu">
|
||||
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="open = false; document.getElementById('service-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="deploying = true; $wire.dispatch('startEvent'); open = false">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@endif
|
||||
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300" role="separator"></div>
|
||||
@if ($serviceStatus->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Pull Latest Images & Restart
|
||||
</button>
|
||||
@elseif ($serviceStatus->contains('degraded'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Deploy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="$wire.dispatch('cleanupEvent'); open = false">
|
||||
<x-reicon name="trash" class="size-3.5 opacity-70" />
|
||||
Force Cleanup Containers
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<button type="button" class="button"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="deploying = true; $wire.dispatch('startEvent'); open = false">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@endif
|
||||
</x-resource-heading-overflow>
|
||||
@else
|
||||
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
|
||||
aria-label="Open required environment variables">
|
||||
|
||||
@@ -286,67 +286,33 @@
|
||||
|
||||
@if ($server->proxySet())
|
||||
@can('manageProxy', $server)
|
||||
<div id="server-desktop-actions" class="resource-heading-actions relative shrink-0"
|
||||
x-data="{ open: false }" x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu" wire:loading.attr="disabled" wire:loading.class="is-loading"
|
||||
wire:target="checkProxy,startProxy">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
@if ($proxyCanBeStopped)
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a class="listbox-option justify-start! gap-2.5!" target="_blank"
|
||||
href="http://{{ $serverIp }}:8080" @click="open = false" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="external-link" class="size-3! opacity-70" />
|
||||
</span>
|
||||
Traefik Dashboard
|
||||
</a>
|
||||
@endif
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Restart Proxy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
</span>
|
||||
Stop Proxy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; $wire.dispatch('checkProxyEvent')" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Start Proxy
|
||||
</button>
|
||||
@endif
|
||||
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300"
|
||||
role="separator"></div>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="checkProxyStatus" wire:loading.attr="disabled"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-server.advanced :server-ip="$serverIp" :traefik-dashboard-available="$traefikDashboardAvailable" />
|
||||
<x-resource-heading-overflow id="server-desktop-actions" class="resource-heading-actions">
|
||||
@if ($proxyCanBeStopped)
|
||||
<button type="button" class="button"
|
||||
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Refresh Proxy Status
|
||||
Restart Proxy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="button"
|
||||
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
</span>
|
||||
Stop Proxy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="button"
|
||||
@click="open = false; $wire.dispatch('checkProxyEvent')">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Start Proxy
|
||||
</button>
|
||||
@endif
|
||||
</x-resource-heading-overflow>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
|
||||
<x-settings.layout>
|
||||
<div class="application-settings-form mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-6">
|
||||
{{-- One bar for the whole page. Three stacked bars made Save run
|
||||
submitResend(), which required an API key even when Resend was off. --}}
|
||||
<x-unsaved-bar action="submit"
|
||||
targets="smtpFromName,smtpFromAddress,smtpHost,smtpPort,smtpEncryption,smtpUsername,smtpPassword,smtpTimeout,resendApiKey" />
|
||||
|
||||
<form wire:submit="submit">
|
||||
<x-unsaved-bar action="submit" />
|
||||
<x-application.settings-section title="Sender">
|
||||
<x-slot:actions>
|
||||
@include('livewire.partials.settings-email-send-test')
|
||||
@@ -21,7 +25,6 @@
|
||||
</form>
|
||||
|
||||
<form wire:submit.prevent="submitSmtp">
|
||||
<x-unsaved-bar action="submitSmtp" />
|
||||
<x-application.settings-section title="SMTP server">
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="lg:col-span-3">
|
||||
@@ -50,7 +53,6 @@
|
||||
</form>
|
||||
|
||||
<form wire:submit.prevent="submitResend">
|
||||
<x-unsaved-bar action="submitResend" />
|
||||
<x-application.settings-section title="Resend">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="resendEnabled" label="Resend delivery"
|
||||
@@ -58,8 +60,8 @@
|
||||
['value' => true, 'label' => 'Enabled'],
|
||||
['value' => false, 'label' => 'Disabled'],
|
||||
]" />
|
||||
<x-forms.input type="password" id="resendApiKey" placeholder="API key" required
|
||||
label="API key" autocomplete="new-password" />
|
||||
<x-forms.input type="password" id="resendApiKey" placeholder="API key"
|
||||
:required="$resendEnabled" label="API key" autocomplete="new-password" />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
currentVersion: config.currentVersion || '',
|
||||
latestVersion: config.latestVersion || '',
|
||||
serviceDown: false,
|
||||
instanceWentDown: false,
|
||||
devMode: config.devMode || false,
|
||||
simulationInterval: null,
|
||||
|
||||
@@ -272,6 +273,60 @@
|
||||
}) >= 0;
|
||||
},
|
||||
|
||||
isReadyToReload(runningVersion) {
|
||||
if (this.hasReachedTargetVersion(runningVersion, this.latestVersion)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Releases before this header existed (e.g. 4.3.1) still
|
||||
// return a healthy /api/health with no X-Coolify-Version.
|
||||
// Only treat that as done after the instance actually went down.
|
||||
return !runningVersion && this.instanceWentDown;
|
||||
},
|
||||
|
||||
startHealthWatch() {
|
||||
if (this.checkHealthInterval) {
|
||||
return;
|
||||
}
|
||||
this.checkHealthInterval = setInterval(() => {
|
||||
this.probeHealth();
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
probeHealth() {
|
||||
this.healthCheckAttempts++;
|
||||
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
|
||||
|
||||
return fetch('/api/health')
|
||||
.then(response => {
|
||||
const runningVersion = response.headers.get('X-Coolify-Version');
|
||||
if (!response.ok) {
|
||||
this.instanceWentDown = true;
|
||||
this.currentStep = 4;
|
||||
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
||||
return;
|
||||
}
|
||||
if (this.isReadyToReload(runningVersion)) {
|
||||
this.showSuccess();
|
||||
return;
|
||||
}
|
||||
if (!this.instanceWentDown && this.currentStep < 4) {
|
||||
return;
|
||||
}
|
||||
if (runningVersion) {
|
||||
this.currentStatus = `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...`;
|
||||
} else {
|
||||
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Health check failed:', error);
|
||||
this.instanceWentDown = true;
|
||||
this.currentStep = 4;
|
||||
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
||||
});
|
||||
},
|
||||
|
||||
getReviveStatusMessage(elapsedMinutes, attempts) {
|
||||
if (elapsedMinutes === 0) {
|
||||
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
|
||||
@@ -287,31 +342,9 @@
|
||||
},
|
||||
|
||||
revive() {
|
||||
if (this.checkHealthInterval) return true;
|
||||
this.healthCheckAttempts = 0;
|
||||
this.currentStep = 4;
|
||||
console.log('Checking server\'s health...');
|
||||
this.checkHealthInterval = setInterval(() => {
|
||||
this.healthCheckAttempts++;
|
||||
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
|
||||
fetch('/api/health')
|
||||
.then(response => {
|
||||
const runningVersion = response.headers.get('X-Coolify-Version');
|
||||
if (response.ok && this.hasReachedTargetVersion(runningVersion, this.latestVersion)) {
|
||||
this.showSuccess();
|
||||
} else if (response.ok) {
|
||||
this.currentStatus = runningVersion
|
||||
? `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...`
|
||||
: this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
||||
} else {
|
||||
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Health check failed:', error);
|
||||
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
||||
});
|
||||
}, 2000);
|
||||
this.startHealthWatch();
|
||||
},
|
||||
|
||||
showSuccess() {
|
||||
@@ -388,7 +421,9 @@
|
||||
this.currentStep = 1;
|
||||
this.currentStatus = 'Starting upgrade...';
|
||||
this.serviceDown = false;
|
||||
this.instanceWentDown = false;
|
||||
this.livewireFailures = 0;
|
||||
this.startHealthWatch();
|
||||
|
||||
// Poll upgrade status via Livewire
|
||||
this.checkUpgradeStatusInterval = setInterval(async () => {
|
||||
@@ -399,25 +434,30 @@
|
||||
this.currentStep = this.mapStepToUI(data.step);
|
||||
this.currentStatus = data.message;
|
||||
} else if (data.status === 'complete') {
|
||||
if (this.hasReachedTargetVersion(data.running_version || this.currentVersion, this.latestVersion)) {
|
||||
if (this.isReadyToReload(data.running_version)) {
|
||||
this.showSuccess();
|
||||
} else {
|
||||
this.currentStep = 4;
|
||||
this.currentStatus = `Waiting for Coolify ${this.latestVersion} to come online...`;
|
||||
this.revive();
|
||||
}
|
||||
} else if (data.status === 'error') {
|
||||
this.showError(data.message);
|
||||
} else if (data.status === 'none' && this.instanceWentDown) {
|
||||
this.revive();
|
||||
await this.probeHealth();
|
||||
}
|
||||
} catch (error) {
|
||||
this.livewireFailures++;
|
||||
if (this.livewireFailures < 3) {
|
||||
this.currentStatus = 'Lost contact with Coolify, retrying...';
|
||||
this.currentStatus = 'Reconnecting. This is expected during an upgrade...';
|
||||
return;
|
||||
}
|
||||
// Repeated Livewire failures usually mean the instance is restarting
|
||||
console.log('Livewire unavailable, switching to health check mode');
|
||||
if (!this.serviceDown) {
|
||||
this.serviceDown = true;
|
||||
this.instanceWentDown = true;
|
||||
this.currentStep = 4;
|
||||
this.currentStatus = 'Coolify is restarting with the new version...';
|
||||
if (this.checkUpgradeStatusInterval) {
|
||||
|
||||
+6
-2
@@ -118,8 +118,12 @@ Route::middleware(['throttle:login'])->group(function () {
|
||||
Route::get('/auth/{provider}/redirect', [OauthController::class, 'redirect'])->name('auth.redirect');
|
||||
Route::get('/auth/{provider}/callback', [OauthController::class, 'callback'])->name('auth.callback');
|
||||
|
||||
// Local-only previews for redesigned HTTP error pages (never registered in production).
|
||||
if (app()->environment('local')) {
|
||||
// Local/testing previews for HTTP error pages and the Laravel debug renderer (never in production).
|
||||
if (app()->environment(['local', 'testing'])) {
|
||||
Route::get('/__exception', function () {
|
||||
throw new RuntimeException('Testing Laravel exception page');
|
||||
})->name('dev.exception-preview');
|
||||
|
||||
Route::get('/__error/{code}', function (string $code) {
|
||||
$allowed = ['400', '401', '402', '403', '404', '419', '429', '500', '503'];
|
||||
abort_unless(in_array($code, $allowed, true), 404);
|
||||
|
||||
@@ -37,6 +37,7 @@ test('advanced action dropdown menus use the grid icon', function () {
|
||||
$files = [
|
||||
resource_path('views/components/applications/advanced.blade.php'),
|
||||
resource_path('views/components/services/advanced.blade.php'),
|
||||
resource_path('views/components/server/advanced.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($files as $path) {
|
||||
|
||||
@@ -206,6 +206,38 @@ test('admin can update email notification settings', function () {
|
||||
expect($this->admin->can('update', $settings))->toBeTrue();
|
||||
});
|
||||
|
||||
test('admin can save team smtp settings without a resend api key when resend is disabled', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(EmailNotification::class)
|
||||
->set('smtpFromAddress', 'alerts@example.com')
|
||||
->set('smtpFromName', 'Coolify')
|
||||
->set('smtpHost', 'smtp.example.com')
|
||||
->set('smtpPort', '587')
|
||||
->set('smtpEncryption', 'starttls')
|
||||
->set('resendEnabled', false)
|
||||
->set('resendApiKey', null)
|
||||
->call('submitResend')
|
||||
->assertHasNoErrors()
|
||||
->assertNotDispatched('error');
|
||||
});
|
||||
|
||||
test('admin cannot enable team resend without an api key', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(EmailNotification::class)
|
||||
->set('smtpFromAddress', 'alerts@example.com')
|
||||
->set('smtpFromName', 'Coolify')
|
||||
->set('resendEnabled', true)
|
||||
->set('resendApiKey', null)
|
||||
->call('submitResend')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->team->emailNotificationSettings()->first()->resend_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
// --- Pushover ---
|
||||
|
||||
test('member cannot send test notification on pushover', function () {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => 'file',
|
||||
'cache.default' => 'array',
|
||||
]);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 404 when the deployment environment does not exist', function () {
|
||||
$this->get(route('project.application.deployment.show', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'deployment_uuid' => 'qn5yfvo8clppyt44b75k7dav',
|
||||
]))->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 when the deployment index environment does not exist', function () {
|
||||
$this->get(route('project.application.deployment.index', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]))->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 when the database backup index environment does not exist', function () {
|
||||
$this->get(route('project.database.backup.index', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
|
||||
'database_uuid' => 'missing-database',
|
||||
]))->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 when the database backup execution environment does not exist', function () {
|
||||
$this->get(route('project.database.backup.execution', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => 'fbmlga1tumj9ndoy16lal93d',
|
||||
'database_uuid' => 'missing-database',
|
||||
'backup_uuid' => 'missing-backup',
|
||||
]))->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
use Illuminate\Contracts\Foundation\ExceptionRenderer;
|
||||
use Illuminate\Foundation\Exceptions\Renderer\Renderer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
});
|
||||
|
||||
test('debug exceptions use laravel renderer instead of ignition', function () {
|
||||
config(['app.debug' => true]);
|
||||
|
||||
expect(class_exists('Spatie\\LaravelIgnition\\IgnitionServiceProvider'))->toBeFalse()
|
||||
->and(app()->bound(ExceptionRenderer::class))->toBeFalse()
|
||||
->and(app()->bound(Renderer::class))->toBeTrue();
|
||||
|
||||
$response = app(ExceptionHandler::class)
|
||||
->render(request(), new RuntimeException('default-laravel-exception-renderer'));
|
||||
|
||||
expect($response->getContent())
|
||||
->toContain('default-laravel-exception-renderer')
|
||||
->toContain('scheme-light-dark')
|
||||
->toContain('dark:bg-neutral-900');
|
||||
});
|
||||
|
||||
test('dev exception preview url renders the laravel debug page', function () {
|
||||
config(['app.debug' => true]);
|
||||
|
||||
$this->get('/__exception')
|
||||
->assertServerError()
|
||||
->assertSee('RuntimeException', false)
|
||||
->assertSee('Testing Laravel exception page')
|
||||
->assertSee('scheme-light-dark', false);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
test('team notification resend section matches instance email layout', function () {
|
||||
$instance = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
|
||||
$team = file_get_contents(resource_path('views/livewire/notifications/email.blade.php'));
|
||||
|
||||
preg_match('/settings-section title="Resend".*?<\/x-application.settings-section>/s', $instance, $instanceResend);
|
||||
preg_match('/settings-section title="Resend".*?<\/x-application.settings-section>/s', $team, $teamResend);
|
||||
|
||||
expect($instanceResend[0] ?? '')
|
||||
->not->toBeEmpty()
|
||||
->toContain('grid gap-4 lg:grid-cols-2')
|
||||
->toContain('id="resendEnabled"')
|
||||
->toContain('id="resendApiKey"')
|
||||
->not->toContain('lg:col-span-2')
|
||||
->not->toContain('description=');
|
||||
|
||||
expect($teamResend[0] ?? '')
|
||||
->not->toBeEmpty()
|
||||
->toContain('grid gap-4 lg:grid-cols-2')
|
||||
->toContain('id="resendEnabled"')
|
||||
->toContain('id="resendApiKey"')
|
||||
->not->toContain('lg:col-span-2')
|
||||
->not->toContain('description=');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
it('collapses resource header actions only when the top bar cannot fit them', function () {
|
||||
$overflow = file_get_contents(resource_path('views/components/resource-heading-overflow.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($overflow)
|
||||
->toContain('data-resource-heading-overflow')
|
||||
->toContain('measureInlineWidth')
|
||||
->toContain('availableWidth')
|
||||
->toContain('reserved += 200')
|
||||
->toContain('hudSiblings')
|
||||
->toContain('Actions')
|
||||
->toContain('listbox-panel top-full! right-0! left-auto!')
|
||||
->toContain("\$dispatch('resource-actions-toggled', { open })");
|
||||
|
||||
expect($css)
|
||||
->toContain('.resource-heading-overflow-items')
|
||||
->toContain('.resource-heading-overflow-items.is-measuring')
|
||||
->toContain('.resource-heading-overflow.is-collapsed');
|
||||
});
|
||||
|
||||
it('uses the overflow group for application, service, database, and server desktop actions', function () {
|
||||
$files = [
|
||||
resource_path('views/livewire/project/application/heading.blade.php') => 'application-desktop-actions',
|
||||
resource_path('views/livewire/project/service/heading.blade.php') => 'service-desktop-actions',
|
||||
resource_path('views/livewire/project/database/heading.blade.php') => 'database-desktop-actions',
|
||||
resource_path('views/livewire/server/navbar.blade.php') => 'server-desktop-actions',
|
||||
];
|
||||
|
||||
foreach ($files as $path => $id) {
|
||||
expect(file_get_contents($path))
|
||||
->toContain('<x-resource-heading-overflow')
|
||||
->toContain($id);
|
||||
}
|
||||
});
|
||||
@@ -113,24 +113,47 @@ it('places the account menu beside the desktop sidebar toggle while retaining it
|
||||
->toContain("'bottom-full! left-0! right-auto! top-auto! mb-1!' => \$sidebar");
|
||||
});
|
||||
|
||||
it('keeps advanced operations in a separated section at the bottom of actions menus', function () {
|
||||
it('keeps advanced operations in a dedicated Advanced dropdown', function () {
|
||||
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
$applicationAdvanced = file_get_contents(resource_path('views/components/applications/advanced.blade.php'));
|
||||
$serviceAdvanced = file_get_contents(resource_path('views/components/services/advanced.blade.php'));
|
||||
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
|
||||
|
||||
$applicationDesktop = str($application)->after('resource-heading-actions flex')->toString();
|
||||
$serviceDesktop = str($service)->after('resource-heading-actions flex')->toString();
|
||||
|
||||
expect($applicationDesktop)
|
||||
->not->toContain('<x-applications.advanced')
|
||||
->toContain('<x-applications.deploy')
|
||||
->toContain('application-desktop-actions')
|
||||
->toContain('role="separator"')
|
||||
->toContain('Force deploy without cache')
|
||||
->not->toContain('<x-applications.advanced')
|
||||
->not->toContain('resource-heading-overflow-separator')
|
||||
->not->toContain('Force deploy without cache')
|
||||
->and($serviceDesktop)
|
||||
->not->toContain('<x-services.advanced')
|
||||
->toContain('<x-services.advanced')
|
||||
->toContain('<x-services.restart')
|
||||
->toContain('service-desktop-actions')
|
||||
->toContain('role="separator"')
|
||||
->toContain('Pull Latest Images & Restart')
|
||||
->not->toContain('resource-heading-overflow-separator')
|
||||
->not->toContain('Pull Latest Images & Restart');
|
||||
|
||||
expect(strpos($applicationDesktop, '<x-applications.links'))
|
||||
->toBeLessThan(strpos($applicationDesktop, '<x-applications.deploy'))
|
||||
->toBeLessThan(strpos($applicationDesktop, 'application-desktop-actions'));
|
||||
|
||||
expect(strpos($serviceDesktop, '<x-services.links'))
|
||||
->toBeLessThan(strpos($serviceDesktop, '<x-services.advanced'))
|
||||
->toBeLessThan(strpos($serviceDesktop, '<x-services.restart'))
|
||||
->toBeLessThan(strpos($serviceDesktop, 'service-desktop-actions'));
|
||||
|
||||
expect($applicationAdvanced)
|
||||
->toContain('Advanced')
|
||||
->toContain('name="grid"')
|
||||
->not->toContain('Force deploy without cache')
|
||||
->and($serviceAdvanced)
|
||||
->toContain('Advanced')
|
||||
->toContain('name="grid"')
|
||||
->not->toContain('Pull Latest Images & Restart')
|
||||
->not->toContain('Pull latest and restart')
|
||||
->toContain('Force Restart')
|
||||
->toContain('Force Deploy')
|
||||
->toContain('Force Cleanup Containers')
|
||||
@@ -142,29 +165,36 @@ it('keeps advanced operations in a separated section at the bottom of actions me
|
||||
expect(substr_count($application, 'resource-heading-navbar'))->toBe(1);
|
||||
});
|
||||
|
||||
it('groups application lifecycle controls in an actions dropdown', function () {
|
||||
it('lists desktop application lifecycle controls inline and collapses them when space runs out', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$overflow = file_get_contents(resource_path('views/components/resource-heading-overflow.blade.php'));
|
||||
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
|
||||
|
||||
expect($desktop)
|
||||
->toContain('application-desktop-actions')
|
||||
->toContain('Actions')
|
||||
->toContain('listbox-panel top-full! right-0! left-auto!')
|
||||
->toContain('Redeploy')
|
||||
->toContain('<x-resource-heading-overflow')
|
||||
->toContain('<x-applications.deploy')
|
||||
->toContain('Restart')
|
||||
->toContain('Stop')
|
||||
->toContain('Deploy');
|
||||
->not->toContain('listbox-option');
|
||||
|
||||
expect($overflow)
|
||||
->toContain('Actions')
|
||||
->toContain('listbox-panel top-full! right-0! left-auto!')
|
||||
->toContain('measureInlineWidth')
|
||||
->toContain('availableWidth');
|
||||
});
|
||||
|
||||
it('raises the desktop top bar while any resource dropdown is open', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
$dropdowns = [
|
||||
resource_path('views/livewire/project/application/heading.blade.php'),
|
||||
resource_path('views/livewire/project/database/heading.blade.php'),
|
||||
resource_path('views/livewire/project/service/heading.blade.php'),
|
||||
resource_path('views/livewire/server/navbar.blade.php'),
|
||||
resource_path('views/components/resource-heading-overflow.blade.php'),
|
||||
resource_path('views/components/applications/links.blade.php'),
|
||||
resource_path('views/components/services/links.blade.php'),
|
||||
resource_path('views/components/applications/deploy.blade.php'),
|
||||
resource_path('views/components/services/advanced.blade.php'),
|
||||
resource_path('views/components/services/restart.blade.php'),
|
||||
resource_path('views/components/server/advanced.blade.php'),
|
||||
];
|
||||
|
||||
expect($layout)
|
||||
@@ -178,15 +208,48 @@ it('raises the desktop top bar while any resource dropdown is open', function ()
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps deploy in the actions menu alongside advanced operations', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
it('groups service restart options in a Restart dropdown', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
$restart = file_get_contents(resource_path('views/components/services/restart.blade.php'));
|
||||
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
|
||||
|
||||
expect($desktop)
|
||||
->toContain("@if (str(\$application->status)->startsWith('exited'))")
|
||||
->toContain('<x-services.restart')
|
||||
->not->toContain('Pull Latest Images & Restart')
|
||||
->and($restart)
|
||||
->toContain('Restart current version')
|
||||
->toContain('Pull latest and restart')
|
||||
->toContain("\$wire.dispatch('pullAndRestartEvent')")
|
||||
->toContain('service-restart-trigger');
|
||||
|
||||
$mobile = str($heading)->before("@teleport('#resource-action-hud-slot')")->toString();
|
||||
|
||||
expect($mobile)
|
||||
->toContain('Restart current version')
|
||||
->toContain('Pull latest and restart')
|
||||
->not->toContain('Pull Latest Images & Restart');
|
||||
});
|
||||
|
||||
it('groups application deploy options in a Deploy dropdown', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$deploy = file_get_contents(resource_path('views/components/applications/deploy.blade.php'));
|
||||
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
|
||||
|
||||
expect($desktop)
|
||||
->toContain('<x-applications.deploy')
|
||||
->toContain('id="application-desktop-actions"')
|
||||
->not->toContain('Force deploy without cache')
|
||||
->and($deploy)
|
||||
->toContain('Deploy')
|
||||
->toContain('Force deploy without cache');
|
||||
->toContain('Deploy (without cache)')
|
||||
->toContain('force_deploy_without_cache')
|
||||
->toContain('deploy(true)');
|
||||
|
||||
$mobile = str($heading)->before("@teleport('#resource-action-hud-slot')")->toString();
|
||||
|
||||
expect($mobile)
|
||||
->toContain('Deploy (without cache)')
|
||||
->not->toContain('Force deploy without cache');
|
||||
});
|
||||
|
||||
it('renders configuration warnings as navbar popovers instead of floating notifications', function () {
|
||||
|
||||
@@ -42,19 +42,34 @@ it('uses the branded input focus state for the server filter', function () {
|
||||
->not->toContain('<x-reicon name="check"');
|
||||
});
|
||||
|
||||
it('groups all desktop proxy controls in the server actions dropdown', function () {
|
||||
it('lists desktop proxy controls inline and keeps Traefik and refresh in Advanced', function () {
|
||||
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||
$desktopActions = str($navbar)->after('id="server-desktop-actions"')->before('@endteleport')->toString();
|
||||
$overflow = file_get_contents(resource_path('views/components/resource-heading-overflow.blade.php'));
|
||||
$advanced = file_get_contents(resource_path('views/components/server/advanced.blade.php'));
|
||||
$desktopActions = str($navbar)->after("@teleport('#resource-action-hud-slot')")->before('@endteleport')->toString();
|
||||
|
||||
expect($desktopActions)
|
||||
->toContain('Actions')
|
||||
->toContain('Traefik Dashboard')
|
||||
->toContain('name="external-link" class="size-3! opacity-70"')
|
||||
->toContain('class="flex size-4 shrink-0 items-center justify-center"')
|
||||
->toContain('Restart Proxy')
|
||||
->toContain('Stop Proxy')
|
||||
->toContain('Start Proxy')
|
||||
->toContain('Refresh Proxy Status')
|
||||
->toContain('listbox-panel')
|
||||
->toContain('<x-server.advanced')
|
||||
->not->toContain('Traefik Dashboard')
|
||||
->not->toContain('Refresh Proxy Status')
|
||||
->not->toContain('resource-heading-overflow-separator')
|
||||
->not->toContain('<x-modal-confirmation');
|
||||
|
||||
expect(strpos($desktopActions, '<x-server.advanced'))
|
||||
->toBeLessThan(strpos($desktopActions, 'id="server-desktop-actions"'));
|
||||
|
||||
expect($advanced)
|
||||
->toContain('Advanced')
|
||||
->toContain('name="grid"')
|
||||
->toContain('Traefik Dashboard')
|
||||
->toContain('name="external-link" class="size-3! opacity-70"')
|
||||
->toContain('class="flex size-4 shrink-0 items-center justify-center"')
|
||||
->toContain('Refresh Proxy Status');
|
||||
|
||||
expect($overflow)
|
||||
->toContain('Actions')
|
||||
->toContain('listbox-panel');
|
||||
});
|
||||
|
||||
@@ -30,10 +30,9 @@ it('matches application action bar behavior for services and databases', functio
|
||||
|
||||
foreach ([$service, $database] as $heading) {
|
||||
expect($heading)
|
||||
->toContain('xl:fixed xl:top-14 xl:right-4')
|
||||
->toContain('@teleport(\'#resource-action-hud-slot\')')
|
||||
->toContain('xl:w-auto')
|
||||
->toContain('Actions')
|
||||
->toContain('listbox-panel top-full! right-0! left-auto!')
|
||||
->toContain('<x-resource-heading-overflow')
|
||||
->not->toContain('hidden lg:block lg:h-12');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\SettingsEmail;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function setupInstanceAdminForEmailSettings(): User
|
||||
{
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
InstanceSettings::forceCreate([
|
||||
'id' => 0,
|
||||
'smtp_enabled' => false,
|
||||
'resend_enabled' => false,
|
||||
]);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function smtpSetupPayload(): array
|
||||
{
|
||||
return [
|
||||
'smtpFromAddress' => 'alerts@example.com',
|
||||
'smtpFromName' => 'Coolify',
|
||||
'smtpHost' => 'smtp.example.com',
|
||||
'smtpPort' => '587',
|
||||
'smtpEncryption' => 'starttls',
|
||||
'resendEnabled' => false,
|
||||
'resendApiKey' => null,
|
||||
];
|
||||
}
|
||||
|
||||
test('saving smtp settings does not require a resend api key when resend is disabled', function () {
|
||||
$user = setupInstanceAdminForEmailSettings();
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => 0]]);
|
||||
|
||||
Livewire::test(SettingsEmail::class)
|
||||
->fill(smtpSetupPayload())
|
||||
->set('smtpEnabled', true)
|
||||
->call('submitSmtp')
|
||||
->assertHasNoErrors()
|
||||
->assertNotDispatched('error');
|
||||
|
||||
$settings = InstanceSettings::find(0);
|
||||
|
||||
expect($settings->smtp_enabled)->toBeTrue()
|
||||
->and($settings->smtp_host)->toBe('smtp.example.com')
|
||||
->and($settings->resend_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('saving transactional email settings does not require a resend api key when resend is disabled', function () {
|
||||
$user = setupInstanceAdminForEmailSettings();
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => 0]]);
|
||||
|
||||
Livewire::test(SettingsEmail::class)
|
||||
->fill(smtpSetupPayload())
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertNotDispatched('error');
|
||||
|
||||
$settings = InstanceSettings::find(0);
|
||||
|
||||
expect($settings->smtp_host)->toBe('smtp.example.com')
|
||||
->and($settings->resend_enabled)->toBeFalse()
|
||||
->and($settings->resend_api_key)->toBeNull();
|
||||
});
|
||||
|
||||
test('enabling smtp delivery does not require a resend api key when resend is disabled', function () {
|
||||
$user = setupInstanceAdminForEmailSettings();
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => 0]]);
|
||||
|
||||
Livewire::test(SettingsEmail::class)
|
||||
->fill(smtpSetupPayload())
|
||||
->set('smtpEnabled', true)
|
||||
->call('instantSaveSmtp')
|
||||
->assertHasNoErrors()
|
||||
->assertNotDispatched('error');
|
||||
|
||||
$settings = InstanceSettings::find(0);
|
||||
|
||||
expect($settings->smtp_enabled)->toBeTrue()
|
||||
->and($settings->resend_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('disabling resend does not require a resend api key', function () {
|
||||
$user = setupInstanceAdminForEmailSettings();
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => 0]]);
|
||||
|
||||
Livewire::test(SettingsEmail::class)
|
||||
->fill(smtpSetupPayload())
|
||||
->set('resendEnabled', false)
|
||||
->set('resendApiKey', null)
|
||||
->call('submitResend')
|
||||
->assertHasNoErrors()
|
||||
->assertNotDispatched('error');
|
||||
});
|
||||
|
||||
test('enabling resend still requires an api key', function () {
|
||||
$user = setupInstanceAdminForEmailSettings();
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => 0]]);
|
||||
|
||||
Livewire::test(SettingsEmail::class)
|
||||
->fill(smtpSetupPayload())
|
||||
->set('resendEnabled', true)
|
||||
->set('resendApiKey', null)
|
||||
->call('submitResend')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(InstanceSettings::find(0)->resend_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('email settings page has a single unsaved bar so smtp save cannot hit resend validation', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
|
||||
|
||||
expect(substr_count($view, '<x-unsaved-bar'))->toBe(1)
|
||||
->and($view)->toContain('action="submit"')
|
||||
->and($view)->not->toContain('action="submitResend"');
|
||||
});
|
||||
@@ -34,6 +34,36 @@ it('initializes latest version during mount from cached versions data', function
|
||||
->assertSee('4.0.0-beta.999');
|
||||
});
|
||||
|
||||
it('does not highlight the current upgrade stage with the warning yellow accent', function () {
|
||||
$progressView = file_get_contents(resource_path('views/components/upgrade-progress.blade.php'));
|
||||
|
||||
expect($progressView)
|
||||
->toContain('bg-neutral-100 text-neutral-900 dark:bg-white/[0.08] dark:text-fg')
|
||||
->not->toContain('dark:text-warning')
|
||||
->not->toContain('dark:bg-warning');
|
||||
});
|
||||
|
||||
it('uses a current-color spinner on upgrade stages instead of the brand purple loader', function () {
|
||||
$progressView = file_get_contents(resource_path('views/components/upgrade-progress.blade.php'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($progressView)
|
||||
->toContain('spinner-current')
|
||||
->not->toContain('loading-indicator')
|
||||
->and($appCss)
|
||||
->toContain('.dark .animate-spin.spinner-current')
|
||||
->toContain('html[data-theme="custom"] .animate-spin.spinner-current')
|
||||
->toContain('color: inherit !important;');
|
||||
});
|
||||
|
||||
it('treats a brief upgrade poll miss as a reconnect, not a lost-contact failure', function () {
|
||||
$upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
|
||||
|
||||
expect($upgradeView)
|
||||
->toContain('Reconnecting. This is expected during an upgrade...')
|
||||
->not->toContain('Lost contact with Coolify');
|
||||
});
|
||||
|
||||
it('uses sidebar state css instead of nested alpine state for upgrade labels', function () {
|
||||
$upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
|
||||
$utilitiesCss = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
@@ -128,6 +128,16 @@ it('waits for the running version to match the target before showing reload', fu
|
||||
->toContain('livewireFailures');
|
||||
});
|
||||
|
||||
it('treats a healthy instance without a version header as ready only after downtime', function () {
|
||||
$upgradeView = file_get_contents(__DIR__.'/../../resources/views/livewire/upgrade.blade.php');
|
||||
|
||||
expect($upgradeView)
|
||||
->toContain('instanceWentDown')
|
||||
->toContain('isReadyToReload')
|
||||
->toContain('startHealthWatch')
|
||||
->toContain('data.status === \'none\'');
|
||||
});
|
||||
|
||||
it('starts the upgrade after the Livewire response so status polling is not blocked', function () {
|
||||
$upgradeComponent = file_get_contents(__DIR__.'/../../app/Livewire/Upgrade.php');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user