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:
parent
592b537c61
commit
5f3108b68d
37 changed files with 1155 additions and 733 deletions
15
DESIGN.md
15
DESIGN.md
|
|
@ -154,6 +154,21 @@ ### Layer-2 navigation
|
|||
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 @@ public function submitResend()
|
|||
$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 @@ public function mount()
|
|||
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 @@ public function mount()
|
|||
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 @@ public function mount()
|
|||
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 @@ public function mount()
|
|||
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 @@ public function submitResend()
|
|||
$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",
|
||||
|
|
|
|||
387
composer.lock
generated
387
composer.lock
generated
|
|
@ -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 @@ .resource-heading-actions {
|
|||
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'])
|
||||
|
||||
79
resources/views/components/applications/deploy.blade.php
Normal file
79
resources/views/components/applications/deploy.blade.php
Normal file
|
|
@ -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
|
||||
149
resources/views/components/resource-heading-overflow.blade.php
Normal file
149
resources/views/components/resource-heading-overflow.blade.php
Normal file
|
|
@ -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>
|
||||
38
resources/views/components/server/advanced.blade.php
Normal file
38
resources/views/components/server/advanced.blade.php
Normal file
|
|
@ -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>
|
||||
49
resources/views/components/services/advanced.blade.php
Normal file
49
resources/views/components/services/advanced.blade.php
Normal file
|
|
@ -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
|
||||
50
resources/views/components/services/restart.blade.php
Normal file
50
resources/views/components/services/restart.blade.php
Normal file
|
|
@ -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 @@ class="grid grid-cols-4 overflow-hidden rounded-[10px] border border-neutral-200
|
|||
<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 @@ class="flex size-5 shrink-0 items-center justify-center rounded-full border text
|
|||
<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 @@ class="button button-highlighted">
|
|||
</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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
<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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
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 @@ class="resource-heading-navbar application-heading-actions flex w-full min-w-0 i
|
|||
<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="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
@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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
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 @@ class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 i
|
|||
<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 @@ class="size-3.5 text-orange-500 dark:text-warning" />
|
|||
|
||||
@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 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
|
|||
currentVersion: config.currentVersion || '',
|
||||
latestVersion: config.latestVersion || '',
|
||||
serviceDown: false,
|
||||
instanceWentDown: false,
|
||||
devMode: config.devMode || false,
|
||||
simulationInterval: null,
|
||||
|
||||
|
|
@ -272,6 +273,60 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
|
|||
}) >= 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 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
|
|||
},
|
||||
|
||||
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 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
|
|||
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 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -118,8 +118,12 @@
|
|||
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 @@
|
|||
$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 @@
|
|||
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 () {
|
||||
|
|
|
|||
79
tests/Feature/DeploymentShowMissingEnvironmentTest.php
Normal file
79
tests/Feature/DeploymentShowMissingEnvironmentTest.php
Normal file
|
|
@ -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();
|
||||
});
|
||||
39
tests/Feature/ExceptionRendererTest.php
Normal file
39
tests/Feature/ExceptionRendererTest.php
Normal file
|
|
@ -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);
|
||||
});
|
||||
25
tests/Feature/NotificationEmailResendLayoutTest.php
Normal file
25
tests/Feature/NotificationEmailResendLayoutTest.php
Normal file
|
|
@ -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=');
|
||||
});
|
||||
36
tests/Feature/ResourceHeadingOverflowTest.php
Normal file
36
tests/Feature/ResourceHeadingOverflowTest.php
Normal file
|
|
@ -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 @@
|
|||
->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 @@
|
|||
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('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 @@
|
|||
->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 @@
|
|||
|
||||
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');
|
||||
}
|
||||
|
||||
|
|
|
|||
137
tests/Feature/SettingsEmailSmtpSetupTest.php
Normal file
137
tests/Feature/SettingsEmailSmtpSetupTest.php
Normal file
|
|
@ -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 @@
|
|||
->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 @@
|
|||
->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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue