mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(auth): stop storing the current team as a user attribute
Login, registration and OAuth set a fake currentTeam attribute on the user. When refreshSession() saved the user in the same request, the query wrote a currentTeam column that does not exist. Keep the team only in the session and read it through currentTeam(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0a144a5d7a
commit
d666a0a956
@@ -81,7 +81,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
}
|
||||
}
|
||||
// Set session variable
|
||||
session(['currentTeam' => $user->currentTeam = $team]);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class BackupNow extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $backup;
|
||||
|
||||
public function backupNow()
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageBackups', $this->backup->database);
|
||||
|
||||
$database = $this->backup->database->refresh();
|
||||
if ($database->id !== 0 && ! str($database->status)->startsWith('running')) {
|
||||
$this->dispatch('error', 'The database must be running to start a backup.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseBackupJob::dispatch($this->backup);
|
||||
$database = $this->backup->database;
|
||||
auditLog('ui.database.backup_started', [
|
||||
'team_id' => $database->team()?->id,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'backup_uuid' => $this->backup->uuid,
|
||||
]);
|
||||
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\New;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class EmptyProject extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function createEmptyProject()
|
||||
{
|
||||
$this->authorize('create', Project::class);
|
||||
|
||||
$project = Project::create([
|
||||
'name' => generate_random_name(),
|
||||
'team_id' => currentTeam()->id,
|
||||
'uuid' => new_public_id(),
|
||||
]);
|
||||
|
||||
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
class EditDomain extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $applicationId;
|
||||
|
||||
public ServiceApplication $application;
|
||||
|
||||
public $domainConflicts = [];
|
||||
|
||||
public $showDomainConflictModal = false;
|
||||
|
||||
public $forceSaveDomains = false;
|
||||
|
||||
public $showPortWarningModal = false;
|
||||
|
||||
public $forceRemovePort = false;
|
||||
|
||||
public $requiredPort = null;
|
||||
|
||||
#[Validate]
|
||||
public ?string $fqdn = null;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'fqdn' => ValidationPatterns::applicationDomainRules(),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
|
||||
$this->authorize('view', $this->application);
|
||||
$this->requiredPort = $this->application->getRequiredPort();
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
|
||||
// Sync to model
|
||||
$this->application->setEditableUrls($this->fqdn);
|
||||
|
||||
$this->application->save();
|
||||
} else {
|
||||
// Sync from model
|
||||
$this->fqdn = $this->application->url;
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
{
|
||||
$this->forceSaveDomains = true;
|
||||
$this->showDomainConflictModal = false;
|
||||
$this->submit();
|
||||
}
|
||||
|
||||
public function confirmRemovePort()
|
||||
{
|
||||
$this->forceRemovePort = true;
|
||||
$this->showPortWarningModal = false;
|
||||
$this->submit();
|
||||
}
|
||||
|
||||
public function cancelRemovePort()
|
||||
{
|
||||
$this->showPortWarningModal = false;
|
||||
$this->syncData(); // Reset to original FQDN
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$persistedApplication = $this->application->fresh();
|
||||
$previousEditableUrls = $persistedApplication->url;
|
||||
$previousFqdn = $persistedApplication->fqdn;
|
||||
$previousPortOverrides = $persistedApplication->domain_port_overrides;
|
||||
$this->authorize('update', $this->application);
|
||||
$this->validate();
|
||||
|
||||
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
|
||||
$warning = sslipDomainWarning($this->fqdn);
|
||||
if ($warning) {
|
||||
$this->dispatch('warning', __('warning.sslipdomain'));
|
||||
}
|
||||
// Sync to model for domain conflict check (without validation)
|
||||
$this->application->setEditableUrls($this->fqdn);
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
|
||||
// Check for required port
|
||||
if (! $this->forceRemovePort) {
|
||||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) {
|
||||
$fqdn = trim((string) $fqdn);
|
||||
if ($fqdn === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->application->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) {
|
||||
$this->requiredPort = $requiredPort;
|
||||
$this->showPortWarningModal = true;
|
||||
$this->application->fqdn = $previousFqdn;
|
||||
$this->application->domain_port_overrides = $previousPortOverrides;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceRemovePort = false;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
$this->application->save();
|
||||
$this->application->refresh();
|
||||
$this->syncData();
|
||||
updateCompose($this->application);
|
||||
if (str($this->application->fqdn)->contains(',')) {
|
||||
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
|
||||
}
|
||||
$this->application->service->parse();
|
||||
$this->dispatch('refresh');
|
||||
$this->dispatch('refreshServices');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
$originalFqdn = $this->application->getOriginal('fqdn');
|
||||
if ($originalFqdn !== $this->application->fqdn) {
|
||||
$this->application->fqdn = $originalFqdn;
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.service.edit-domain');
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class UploadConfig extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $config;
|
||||
|
||||
public $applicationId;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->config = '{
|
||||
"build_pack": "nixpacks",
|
||||
"base_directory": "/nodejs",
|
||||
"publish_directory": "/",
|
||||
"ports_exposes": "3000",
|
||||
"settings": {
|
||||
"is_static": false
|
||||
}
|
||||
}';
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadConfig()
|
||||
{
|
||||
try {
|
||||
$application = Application::ownedByCurrentTeam()->findOrFail($this->applicationId);
|
||||
$this->authorize('update', $application);
|
||||
$application->setConfig($this->config);
|
||||
$this->dispatch('success', 'Application settings updated');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.upload-config');
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Tags;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use Livewire\Component;
|
||||
|
||||
class Deployments extends Component
|
||||
{
|
||||
public $deploymentsPerTagPerServer = [];
|
||||
|
||||
public $resourceIds = [];
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.tags.deployments');
|
||||
}
|
||||
|
||||
public function getDeployments()
|
||||
{
|
||||
try {
|
||||
$applicationIds = Application::ownedByCurrentTeam()->whereIn('id', $this->resourceIds)->pluck('id');
|
||||
$this->deploymentsPerTagPerServer = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('application_id', $applicationIds)->get([
|
||||
'id',
|
||||
'application_id',
|
||||
'application_name',
|
||||
'deployment_url',
|
||||
'pull_request_id',
|
||||
'server_name',
|
||||
'server_id',
|
||||
'status',
|
||||
])->sortBy('id')->groupBy('server_name')->toArray();
|
||||
$this->dispatch('deployments', $this->deploymentsPerTagPerServer);
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Team\Storage;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
public $storage = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->storage = S3Storage::ownedByCurrentTeam()->whereUuid(request()->storage_uuid)->first();
|
||||
if (! $this->storage) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.storage.show');
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
public function toResponse($request)
|
||||
{
|
||||
// First user (root) will be redirected to /settings instead of / on registration.
|
||||
if ($request->user()->currentTeam->id === 0) {
|
||||
if ($request->user()->currentTeam()?->id === 0) {
|
||||
return redirect()->route('settings.index');
|
||||
}
|
||||
|
||||
@@ -88,9 +88,9 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
if (! $user->teams()->where('team_id', $invitation->team->id)->exists()) {
|
||||
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
|
||||
}
|
||||
$user->currentTeam = $invitation->team;
|
||||
$team = $invitation->team;
|
||||
$invitation->delete();
|
||||
session(['currentTeam' => $user->currentTeam]);
|
||||
session(['currentTeam' => $team]);
|
||||
} else {
|
||||
// Restore the last active team; only fall back when unambiguous.
|
||||
$team = $user->resolveStoredTeam();
|
||||
@@ -98,7 +98,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
$team = $user->recreate_personal_team();
|
||||
}
|
||||
if ($team) {
|
||||
session(['currentTeam' => $user->currentTeam = $team]);
|
||||
session(['currentTeam' => $team]);
|
||||
}
|
||||
// Otherwise (multiple teams, no stored choice) leave the session
|
||||
// team unset so the user is sent to the team-selection screen.
|
||||
|
||||
@@ -29,7 +29,7 @@ class OauthLoginService
|
||||
: $this->resolveOauthUser($oauthUser, $oauthSetting, $email);
|
||||
|
||||
$team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team();
|
||||
session(['currentTeam' => $user->currentTeam = $team]);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
if ($this->requiresTwoFactorChallenge($user)) {
|
||||
Auth::logout();
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components\Forms;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class Select extends Component
|
||||
{
|
||||
public ?string $modelBinding = null;
|
||||
|
||||
public ?string $htmlId = null;
|
||||
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public ?string $id = null,
|
||||
public ?string $name = null,
|
||||
public ?string $label = null,
|
||||
public ?string $helper = null,
|
||||
public bool $required = false,
|
||||
public bool $disabled = false,
|
||||
public string $defaultClass = 'select w-full',
|
||||
public ?string $canGate = null,
|
||||
public mixed $canResource = null,
|
||||
public bool $autoDisable = true,
|
||||
) {
|
||||
// Handle authorization-based disabling
|
||||
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||
|
||||
if (! $hasPermission) {
|
||||
$this->disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
// Store original ID for wire:model binding (property name)
|
||||
$this->modelBinding = $this->id;
|
||||
|
||||
if (is_null($this->id)) {
|
||||
$this->id = new_public_id();
|
||||
// Don't create wire:model binding for auto-generated IDs
|
||||
$this->modelBinding = 'null';
|
||||
}
|
||||
|
||||
// Generate unique HTML ID by adding random suffix
|
||||
// This prevents duplicate IDs when multiple forms are on the same page
|
||||
if ($this->modelBinding && $this->modelBinding !== 'null') {
|
||||
// Use original ID with random suffix for uniqueness
|
||||
$uniqueSuffix = new_public_id();
|
||||
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
|
||||
} else {
|
||||
$this->htmlId = (string) $this->id;
|
||||
}
|
||||
|
||||
if (is_null($this->name)) {
|
||||
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
|
||||
}
|
||||
|
||||
return view('components.forms.select');
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class Modal extends Component
|
||||
{
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $modalId,
|
||||
public ?string $submitWireAction = null,
|
||||
public ?string $modalTitle = null,
|
||||
public ?string $modalBody = null,
|
||||
public ?string $modalSubmit = null,
|
||||
public bool $noSubmit = false,
|
||||
public bool $yesOrNo = false,
|
||||
public string $action = 'delete'
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.modal');
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class ResourceView extends Component
|
||||
{
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public ?string $wire = null,
|
||||
public ?string $logo = null,
|
||||
public ?string $documentation = null,
|
||||
public bool $upgrade = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.resource-view');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components\Services;
|
||||
|
||||
use App\Models\Service;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class Advanced extends Component
|
||||
{
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public ?Service $service = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.services.advanced');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components\Services;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class Explanation extends Component
|
||||
{
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.services.explanation');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components\Status;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public $resource = null,
|
||||
public bool $showRefreshButton = true,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.status.index');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components\Status;
|
||||
|
||||
use App\Models\Service;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class Services extends Component
|
||||
{
|
||||
/**
|
||||
* Create a new component instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Service $service,
|
||||
public string $complexStatus = 'exited',
|
||||
public bool $showRefreshButton = true
|
||||
) {
|
||||
$this->complexStatus = $service->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view / contents that represent the component.
|
||||
*/
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.status.services');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{{-- 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'])
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
@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
|
||||
@@ -1,30 +0,0 @@
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('confirmModal', () => ({
|
||||
open: false,
|
||||
confirmAction: null,
|
||||
message: 'Are you sure?',
|
||||
toggleConfirmModal(customMessage, confirmAction) {
|
||||
this.confirmAction = confirmAction
|
||||
this.message = customMessage
|
||||
this.open = !this.open
|
||||
},
|
||||
confirmed() {
|
||||
this.open = false
|
||||
this.$dispatch(this.confirmAction)
|
||||
}
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
<div x-cloak x-show="open" x-transition.opacity class="fixed inset-0 bg-slate-900/75"></div>
|
||||
<div x-cloak x-show="open" x-transition class="fixed inset-0 z-50 flex pt-10">
|
||||
<div @click.away="open = false" class="w-screen h-20 max-w-xl mx-auto bg-black rounded-lg">
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<div class="pb-5 dark:text-white" x-text="message"></div>
|
||||
<div>
|
||||
<x-forms.button x-on:click='confirmed()'>Confirm</x-forms.button>
|
||||
<x-forms.button x-on:click="open = false">Cancel</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,84 +0,0 @@
|
||||
@props([
|
||||
'inline' => false,
|
||||
'triggerClass' => '',
|
||||
'panelClass' => '',
|
||||
])
|
||||
|
||||
<div x-data="{
|
||||
dropdownOpen: false,
|
||||
panelStyles: '',
|
||||
open() {
|
||||
this.dropdownOpen = true;
|
||||
this.updatePanelPosition();
|
||||
},
|
||||
close() {
|
||||
this.dropdownOpen = false;
|
||||
},
|
||||
updatePanelPosition() {
|
||||
if ({{ $inline ? 'true' : 'false' }} || window.innerWidth >= 768) {
|
||||
this.panelStyles = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
const triggerRect = this.$refs.trigger.getBoundingClientRect();
|
||||
const panelRect = this.$refs.panel.getBoundingClientRect();
|
||||
const viewportPadding = 8;
|
||||
let left = triggerRect.left;
|
||||
|
||||
if ((left + panelRect.width + viewportPadding) > window.innerWidth) {
|
||||
left = window.innerWidth - panelRect.width - viewportPadding;
|
||||
}
|
||||
|
||||
left = Math.max(viewportPadding, left);
|
||||
|
||||
let top = triggerRect.bottom + 4;
|
||||
const maxTop = window.innerHeight - panelRect.height - viewportPadding;
|
||||
|
||||
if (top > maxTop) {
|
||||
top = Math.max(viewportPadding, triggerRect.top - panelRect.height - viewportPadding);
|
||||
}
|
||||
|
||||
this.panelStyles = `position: fixed; left: ${left}px; top: ${top}px;`;
|
||||
});
|
||||
}
|
||||
}" @class(['relative', 'w-full' => $inline]) @click.outside="if (! {{ $inline ? 'true' : 'false' }}) close()" x-on:resize.window="if (dropdownOpen) updatePanelPosition()">
|
||||
<button type="button" x-ref="trigger" @click="dropdownOpen ? close() : open()"
|
||||
@class([
|
||||
'inline-flex items-center justify-start pr-8 transition-colors focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none',
|
||||
'w-full border border-neutral-300 bg-white px-3 py-2 text-left dark:border-coolgray-300 dark:bg-coolgray-100' => $inline,
|
||||
$triggerClass,
|
||||
])>
|
||||
<span class="flex flex-col items-start h-full leading-none">
|
||||
{{ $title }}
|
||||
</span>
|
||||
<svg class="absolute right-0 w-4 h-4 mr-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div x-ref="panel" x-show="dropdownOpen" @click.away="if (! {{ $inline ? 'true' : 'false' }}) close()"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
|
||||
:style="panelStyles" @class([
|
||||
'origin-top' => true,
|
||||
'mt-1 w-full' => $inline,
|
||||
'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline,
|
||||
]) x-cloak>
|
||||
<div @class([
|
||||
'border border-neutral-300 bg-white p-1 dark:border-coolgray-300',
|
||||
'shadow-[var(--shadow-dropdown)] dark:bg-coolgray-200' => ! $inline,
|
||||
'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline,
|
||||
$panelClass,
|
||||
])>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,91 +0,0 @@
|
||||
@props([
|
||||
'label' => 'Domains',
|
||||
'helper' => 'Add one domain per entry. Press Enter (or type a comma) to add it. You can specify a path and a port to bind the domain to.<br><br><span class=\'text-helper\'>Example</span><br>- https://app.coolify.io/api/v3<br>- https://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container.<br>- https://app.coolify.io:8080/api -> app.coolify.io/api will point to port 8080 inside the container.',
|
||||
'model' => 'fqdn',
|
||||
'disabled' => false,
|
||||
'required' => false,
|
||||
'placeholder' => 'https://coolify.io',
|
||||
'canUpdate' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$canEdit = $canUpdate ?? ! $disabled;
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->class(['w-full']) }}>
|
||||
@if ($label)
|
||||
<label class="mb-1 flex w-fit items-center gap-1.5 text-sm font-medium">
|
||||
{{ $label }}
|
||||
@if ($required)
|
||||
<x-highlighted text="*" />
|
||||
@endif
|
||||
@if ($helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
</label>
|
||||
@endif
|
||||
<div class="chip-input" x-data="{
|
||||
raw: @entangle($model),
|
||||
entry: '',
|
||||
canUpdate: @js((bool) $canEdit),
|
||||
get domains() {
|
||||
return (this.raw ?? '').split(',').map((domain) => domain.trim()).filter(Boolean);
|
||||
},
|
||||
commit(list) {
|
||||
this.raw = list.join(',');
|
||||
},
|
||||
addValue(value) {
|
||||
value = value.trim().replace(/,+$/, '');
|
||||
if (!value) return;
|
||||
const list = this.domains;
|
||||
if (!list.includes(value)) {
|
||||
list.push(value);
|
||||
this.commit(list);
|
||||
}
|
||||
},
|
||||
add() {
|
||||
const value = this.entry;
|
||||
this.entry = '';
|
||||
this.addValue(value);
|
||||
},
|
||||
onInput() {
|
||||
if (this.entry.includes(',')) {
|
||||
const parts = this.entry.split(',');
|
||||
this.entry = parts.pop().trim();
|
||||
parts.forEach((part) => this.addValue(part));
|
||||
}
|
||||
},
|
||||
remove(index) {
|
||||
if (!this.canUpdate) return;
|
||||
const list = this.domains;
|
||||
list.splice(index, 1);
|
||||
this.commit(list);
|
||||
},
|
||||
onKeydown(event) {
|
||||
if (event.key === ',') {
|
||||
event.preventDefault();
|
||||
this.add();
|
||||
} else if (event.key === 'Backspace' && this.entry === '') {
|
||||
this.remove(this.domains.length - 1);
|
||||
}
|
||||
}
|
||||
}" @click="$refs.domainEntry.focus()">
|
||||
<template x-for="(domain, index) in domains" :key="domain + '-' + index">
|
||||
<span class="chip">
|
||||
<span x-text="domain"></span>
|
||||
<button type="button" class="chip-remove" x-show="canUpdate"
|
||||
:aria-label="'Remove ' + domain" @click.stop="remove(index)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="2" stroke="currentColor" class="size-3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
<input x-ref="domainEntry" x-model="entry"
|
||||
:placeholder="domains.length === 0 ? @js($placeholder) : ''"
|
||||
autocomplete="off" spellcheck="false" @input="onInput()"
|
||||
@keydown.enter.prevent.stop="add()" x-on:keydown="onKeydown($event)"
|
||||
@blur="add()" @disabled($disabled) x-bind:disabled="!canUpdate" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
<div class="w-full">
|
||||
@if ($label)
|
||||
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
|
||||
<label
|
||||
class="mb-0! flex items-center gap-1 text-sm font-medium leading-4 {{ $disabled ? 'text-neutral-600' : '' }}">{{ $label }}
|
||||
@if ($required)
|
||||
<x-highlighted text="*" />
|
||||
@endif
|
||||
</label>
|
||||
@if ($helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
|
||||
wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}"
|
||||
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @else wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif>
|
||||
{{ $slot }}
|
||||
</select>
|
||||
@error($modelBinding)
|
||||
<label class="label">
|
||||
<span class="text-red-500 label-text-alt">{{ $message }}</span>
|
||||
</label>
|
||||
@enderror
|
||||
</div>
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg class="inline-flex w-4 h-4 text-black dark:text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-6 6l6-6m-6-6l6 6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 272 B |
@@ -1,56 +0,0 @@
|
||||
<dialog id="{{ $modalId }}" class="modal">
|
||||
@if ($yesOrNo)
|
||||
<form method="dialog" class="rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col" @if (!$noSubmit) wire:submit='submit' @endif>
|
||||
<div class="flex items-start overflow-y-auto" style="-webkit-overflow-scrolling: touch;">
|
||||
<div class="flex items-center justify-center shrink-0 w-10 h-10 mr-4 rounded-full">
|
||||
<svg class="w-8 h-8 text-error" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
@isset($modalTitle)
|
||||
<h3 class="text-lg font-bold">{{ $modalTitle }}</h3>
|
||||
@endisset
|
||||
@isset($modalBody)
|
||||
{{ $modalBody }}
|
||||
@endisset
|
||||
@if ($modalSubmit)
|
||||
{{ $modalSubmit }}
|
||||
@else
|
||||
<div class="flex gap-4 mt-4">
|
||||
<x-forms.button class="w-24 bg-coolgray-200 hover:bg-coolgray-100" type="button"
|
||||
onclick="{{ $modalId }}.close()">Cancel
|
||||
</x-forms.button>
|
||||
<div class="flex-1"></div>
|
||||
<x-forms.button class="w-24" isError type="button"
|
||||
wire:click.prevent='{{ $action }}' onclick="{{ $modalId }}.close()">Continue
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<form method="dialog" class="flex flex-col w-11/12 max-w-5xl max-h-[calc(100vh-5rem)] gap-2 rounded-sm modal-box overflow-y-auto"
|
||||
style="-webkit-overflow-scrolling: touch;"
|
||||
@if ($submitWireAction) wire:submit={{ $submitWireAction }} @endif
|
||||
@if (!$noSubmit && !$submitWireAction) wire:submit='submit' @endif>
|
||||
@isset($modalTitle)
|
||||
<h3 class="text-lg font-bold">{{ $modalTitle }}</h3>
|
||||
@endisset
|
||||
@isset($modalBody)
|
||||
{{ $modalBody }}
|
||||
@endisset
|
||||
@if ($modalSubmit)
|
||||
{{ $modalSubmit }}
|
||||
@endif
|
||||
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -1,8 +0,0 @@
|
||||
@props([
|
||||
'title' => 'Notifications',
|
||||
'subtitle' => 'Delivery channels for team events',
|
||||
// Topbar + channel tabs identify the page at xl+; keep the H1 on tablet.
|
||||
'titleOnDesktop' => false,
|
||||
])
|
||||
|
||||
<x-dashboard.navbar section="notifications" :title="$title" :subtitle="$subtitle" :titleOnDesktop="$titleOnDesktop" />
|
||||
@@ -1,444 +0,0 @@
|
||||
@props([
|
||||
'showSubscribeButtons' => true,
|
||||
])
|
||||
<div x-data="{ selected: 'monthly' }" class="w-full pb-20">
|
||||
<div class="px-6 mx-auto lg:px-8">
|
||||
<div class="flex justify-center">
|
||||
<fieldset
|
||||
class="grid grid-cols-2 p-1 text-xs font-semibold leading-5 text-center rounded-sm dark:text-white gap-x-1 bg-white/5">
|
||||
<legend class="sr-only">Payment frequency</legend>
|
||||
<label class="cursor-pointer rounded-sm px-2.5 py-1"
|
||||
:class="selected === 'monthly' ? 'bg-coollabs-100 text-white' : ''">
|
||||
<input type="radio" x-on:click="selected = 'monthly'" name="frequency" value="monthly"
|
||||
class="sr-only">
|
||||
<span>Monthly</span>
|
||||
</label>
|
||||
<label class="cursor-pointer rounded-sm px-2.5 py-1"
|
||||
:class="selected === 'yearly' ? 'bg-coollabs-100 text-white' : ''">
|
||||
<input type="radio" x-on:click="selected = 'yearly'" name="frequency" value="annually"
|
||||
class="sr-only">
|
||||
<span>Annually</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
@if (config('constants.limits.trial_period') > 0)
|
||||
<div class="py-2 text-center"><span
|
||||
class="font-bold dark:text-warning">{{ config('constants.limits.trial_period') }}
|
||||
days trial</span> included on all plans, without credit card details.</div>
|
||||
@endif
|
||||
<div x-show="selected === 'monthly'" class="flex justify-center h-10 mt-3 text-sm leading-6 ">
|
||||
<div>Save <span class="font-bold text-black dark:text-warning">10%</span> annually with the yearly plans.
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="selected === 'yearly'" class="flex justify-center h-10 mt-3 text-sm leading-6 ">
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 rounded-sm bg-coolgray-400">
|
||||
<h2 id="tier-hobby" class="flex items-start gap-4 text-4xl font-bold tracking-tight">Unlimited Trial
|
||||
<x-forms.button><a class="font-bold dark:text-white hover:no-underline"
|
||||
href="https://github.com/coollabsio/coolify">Get Started</a></x-forms.button>
|
||||
</h2>
|
||||
<p class="mt-4 text-sm leading-6">Start self-hosting <span class="dark:text-warning">without limits</span>
|
||||
with
|
||||
our
|
||||
OSS version. Same features as the paid version, but you have to manage by yourself.</p>
|
||||
</div>
|
||||
|
||||
<div class="flow-root mt-12">
|
||||
<div class="pb-10 text-xl text-center">For the detailed list of features, please visit our landing page: <a
|
||||
class="font-bold underline dark:text-white" href="https://coolify.io">coolify.io</a></div>
|
||||
<div
|
||||
class="grid max-w-sm grid-cols-1 -mt-16 divide-y divide-neutral-200 dark:divide-coolgray-500 isolate gap-y-16 sm:mx-auto lg:-mx-8 lg:mt-0 lg:max-w-none lg:grid-cols-3 lg:divide-x lg:divide-y-0 xl:-mx-4">
|
||||
|
||||
<div class="pt-16 lg:px-8 lg:pt-0 xl:px-14">
|
||||
<h3 id="tier-basic" class="text-base font-semibold leading-7 dark:text-white">Basic</h3>
|
||||
<p class="flex items-baseline mt-6 gap-x-1">
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">$5</span>
|
||||
<span class="text-sm font-semibold leading-6 ">/month + VAT</span>
|
||||
</span>
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">$4</span>
|
||||
<span class="text-sm font-semibold leading-6 ">/month + VAT</span>
|
||||
</span>
|
||||
</p>
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
<span>billed monthly</span>
|
||||
</span>
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
<span>billed annually</span>
|
||||
</span>
|
||||
@if ($showSubscribeButtons)
|
||||
@isset($basic)
|
||||
{{ $basic }}
|
||||
@endisset
|
||||
@endif
|
||||
<p class="mt-10 text-sm leading-6 dark:text-white h-[6.5rem]">Begin hosting your own services in the
|
||||
cloud.
|
||||
</p>
|
||||
<ul role="list" class="space-y-3 text-sm leading-6 ">
|
||||
<li class="flex">
|
||||
<svg class="flex-none w-5 h-6 mr-3 dark:text-warning" viewBox="0 0 20 20"
|
||||
fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.775 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Connect <span class="px-1 font-bold dark:text-white">2</span> servers
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Included Email System
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Email Support
|
||||
</li>
|
||||
<li class="flex font-bold dark:text-white gap-x-3">
|
||||
<svg width="512" height="512" class="flex-none w-5 h-6 text-green-600"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2">
|
||||
<path
|
||||
d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3-5a9 9 0 0 0 6-8a3 3 0 0 0-3-3a9 9 0 0 0-8 6a6 6 0 0 0-5 3" />
|
||||
<path d="M7 14a6 6 0 0 0-3 6a6 6 0 0 0 6-3m4-8a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
|
||||
</g>
|
||||
</svg>
|
||||
+ All upcoming features
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pt-16 lg:px-8 lg:pt-0 xl:px-14">
|
||||
<h3 id="tier-pro" class="text-base font-semibold leading-7 dark:text-white">Pro</h3>
|
||||
<p class="flex items-baseline mt-6 gap-x-1">
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">$30</span>
|
||||
<span class="text-sm font-semibold leading-6 ">/month + VAT</span>
|
||||
</span>
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">$27</span>
|
||||
<span class="text-sm font-semibold leading-6 ">/month + VAT</span>
|
||||
</span>
|
||||
</p>
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
<span>billed monthly</span>
|
||||
</span>
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
<span>billed annually</span>
|
||||
</span>
|
||||
@if ($showSubscribeButtons)
|
||||
@isset($pro)
|
||||
{{ $pro }}
|
||||
@endisset
|
||||
@endif
|
||||
<p class="h-20 mt-10 text-sm leading-6 dark:text-white">Expand your business or set up your own
|
||||
hosting
|
||||
environment.
|
||||
</p>
|
||||
<ul role="list" class="mt-6 space-y-3 text-sm leading-6 ">
|
||||
<li class="flex ">
|
||||
<svg class="flex-none w-5 h-6 mr-3 dark:text-warning" viewBox="0 0 20 20"
|
||||
fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Connect <span class="px-1 font-bold dark:text-white">10</span> servers
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Included Email System
|
||||
</li>
|
||||
<li class="flex gap-x-3">
|
||||
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Priority Email Support
|
||||
</li>
|
||||
<li class="flex font-bold dark:text-white gap-x-3">
|
||||
<svg width="512" height="512" class="flex-none w-5 h-6 text-green-600"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2">
|
||||
<path
|
||||
d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3-5a9 9 0 0 0 6-8a3 3 0 0 0-3-3a9 9 0 0 0-8 6a6 6 0 0 0-5 3" />
|
||||
<path d="M7 14a6 6 0 0 0-3 6a6 6 0 0 0 6-3m4-8a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
|
||||
</g>
|
||||
</svg>
|
||||
+ All upcoming features
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pt-16 lg:px-8 lg:pt-0 xl:px-12">
|
||||
<h3 id="tier-ultimate" class="text-base font-semibold leading-7 dark:text-white">Ultimate</h3>
|
||||
<p class="flex items-baseline mt-6 gap-x-1">
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">Custom</span>
|
||||
{{-- <span class="text-sm font-semibold leading-6 ">pay-as-you-go</span> --}}
|
||||
</span>
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
<span class="text-4xl font-bold tracking-tight dark:text-white">Custom</span>
|
||||
{{-- <span class="text-sm font-semibold leading-6 ">/month + VAT</span> --}}
|
||||
</span>
|
||||
</p>
|
||||
<span x-show="selected === 'monthly'" x-cloak>
|
||||
<span>pay-as-you-go</span>
|
||||
</span>
|
||||
<span x-show="selected === 'yearly'" x-cloak>
|
||||
<span>pay-as-you-go</span>
|
||||
</span>
|
||||
@if ($showSubscribeButtons)
|
||||
@isset($ultimate)
|
||||
{{ $ultimate }}
|
||||
@endisset
|
||||
@endif
|
||||
<p class="h-20 mt-10 text-sm leading-6 dark:text-white">Easily manage complex infrastructures in a
|
||||
single location.</p>
|
||||
<ul role="list" class="mt-6 space-y-3 text-sm leading-6 ">
|
||||
<li class="flex ">
|
||||
<svg class="flex-none w-5 h-6 mr-3 dark:text-warning" viewBox="0 0 20 20"
|
||||
fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Connect <span class="px-1 font-bold dark:text-white">10+</span> servers
|
||||
</li>
|
||||
|
||||
<li class="flex gap-x-3">
|
||||
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Included Email System
|
||||
</li>
|
||||
<li class="flex font-bold dark:text-white gap-x-3">
|
||||
<svg class="flex-none w-5 h-6 dark:text-warning" viewBox="0 0 20 20" fill="currentColor"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Priority (Email/Chat) Support
|
||||
</li>
|
||||
<li class="flex font-bold dark:text-white gap-x-3">
|
||||
<svg width="512" height="512" class="flex-none w-5 h-6 text-green-600"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2">
|
||||
<path
|
||||
d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3-5a9 9 0 0 0 6-8a3 3 0 0 0-3-3a9 9 0 0 0-8 6a6 6 0 0 0-5 3" />
|
||||
<path d="M7 14a6 6 0 0 0-3 6a6 6 0 0 0 6-3m4-8a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
|
||||
</g>
|
||||
</svg>
|
||||
+ All upcoming features
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{-- <div class="p-4 mt-10 rounded-sm">
|
||||
<div class="flex items-start gap-4 text-xl tracking-tight">Need official support for
|
||||
your self-hosted instance?
|
||||
<x-forms.button>
|
||||
<a class="font-bold dark:text-white hover:no-underline"
|
||||
href="{{ config('constants.urls.contact') }}">Contact
|
||||
Us</a>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div> --}}
|
||||
</div>
|
||||
|
||||
{{-- <div class="pt-8 pb-12 text-4xl font-bold text-center dark:text-white">Included in all plans</div>
|
||||
<div class="grid grid-cols-1 gap-10 md:grid-cols-2 gap-y-28">
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg dark:text-white bg-coolgray-500">
|
||||
<svg width="512" height="512" class="icon" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm12 13H6a3 3 0 0 1-3-3v-2a3 3 0 0 1 3-3h12M7 8v.01M7 16v.01M20 15l-2 3h3l-2 3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold dark:text-white">Bring Your Own Servers</div>
|
||||
</div>
|
||||
<div class="mt-1 text-base leading-7 text-gray-300">
|
||||
Bring your own server from any cloud providers, or even your own server at home! All you need is SSH
|
||||
access. You will have full control over your server, and you can even use it for other purposes.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg dark:text-white bg-coolgray-500">
|
||||
<svg width="512" height="512" class="icon" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2">
|
||||
<path
|
||||
d="M7 7h10a2 2 0 0 1 2 2v1l1 1v3l-1 1v3a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-3l-1-1v-3l1-1V9a2 2 0 0 1 2-2zm3 9h4" />
|
||||
<circle cx="8.5" cy="11.5" r=".5" fill="#000000" />
|
||||
<circle cx="15.5" cy="11.5" r=".5" fill="#000000" />
|
||||
<path d="M9 7L8 3m7 4l1-4" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold dark:text-white">Server Automations</div>
|
||||
</div>
|
||||
<div class="mt-1 text-base leading-7 text-gray-300">
|
||||
Once you connected your server, Coolify will start managing it and do a
|
||||
lot of administrative tasks for you. You can also write your own scripts to
|
||||
automate your server<span class="dark:text-warning">*</span>.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg dark:text-white bg-coolgray-500">
|
||||
<svg width="512" height="512" viewBox="0 0 24 24" class="icon"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2">
|
||||
<path
|
||||
d="M15 11h2a2 2 0 0 1 2 2v2m0 4a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h4" />
|
||||
<path
|
||||
d="M11 16a1 1 0 1 0 2 0a1 1 0 1 0-2 0m-3-5V8m.347-3.631A4 4 0 0 1 16 6M3 3l18 18" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold dark:text-white">No Vendor Lock-in</div>
|
||||
</div>
|
||||
<div class="mt-1 text-base leading-7 text-gray-300">
|
||||
You own your own data. All configurations saved on your own servers, so if
|
||||
you decide to stop using Coolify, you can still continue to manage your
|
||||
deployed resources.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg dark:text-white bg-coolgray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<rect x="3" y="4" width="18" height="12" rx="1" />
|
||||
<path d="M7 20h10" />
|
||||
<path d="M9 16v4" />
|
||||
<path d="M15 16v4" />
|
||||
<path d="M7 10h2l2 3l2 -6l1 3h3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold dark:text-white">Monitoring</div>
|
||||
</div>
|
||||
<div class="mt-1 text-base leading-7 text-gray-300">
|
||||
Coolify will automatically monitor your configured servers and deployed
|
||||
resources. Notifies you if something goes wrong on your favourite
|
||||
channels, like Discord, Telegram, via Email and more...
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg dark:text-white bg-coolgray-500">
|
||||
<svg width="512" height="512" class="icon" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2">
|
||||
<path d="M6 4h10l4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2" />
|
||||
<path d="M10 14a2 2 0 1 0 4 0a2 2 0 1 0-4 0m4-10v4H8V4" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold dark:text-white">Automatic Backups</div>
|
||||
</div>
|
||||
<div class="mt-1 text-base leading-7 text-gray-300">
|
||||
We automatically backup your databases to any S3 compatible solution. If
|
||||
something goes wrong, you can easily restore your data with a few clicks.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg dark:text-white bg-coolgray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<polyline points="5 7 10 12 5 17" />
|
||||
<line x1="13" y1="17" x2="19" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold dark:text-white">Powerful API</div>
|
||||
</div>
|
||||
<div class="mt-1 text-base leading-7 text-gray-300">
|
||||
Programmatically deploy, query, and manage your servers & resources.
|
||||
Integrate to your CI/CD pipelines, or build your own custom integrations. <span
|
||||
class="dark:text-warning">*</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg dark:text-white bg-coolgray-500">
|
||||
<svg width="512" height="512" class="icon" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2">
|
||||
<path
|
||||
d="M4 18a2 2 0 1 0 4 0a2 2 0 1 0-4 0M4 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0m12 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0M6 8v8" />
|
||||
<path d="M11 6h5a2 2 0 0 1 2 2v8" />
|
||||
<path d="m14 9l-3-3l3-3" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold dark:text-white">Push to Deploy</div>
|
||||
</div>
|
||||
<div class="mt-1 text-base leading-7 text-gray-300">
|
||||
Git integration is default today. We support hosted (github.com,
|
||||
gitlab.com<span class="inline-block dark:text-warning">*</span>) or self-hosted<span
|
||||
class="dark:text-warning">*</span>
|
||||
(Github Enterprise, Gitlab) Git repositories.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg dark:text-white bg-coolgray-500">
|
||||
<svg width="512" height="512" class="icon" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 13a2 2 0 1 0 4 0a2 2 0 0 0-4 0m-2 8v-1a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1M15 5a2 2 0 1 0 4 0a2 2 0 0 0-4 0m2 5h2a2 2 0 0 1 2 2v1M5 5a2 2 0 1 0 4 0a2 2 0 0 0-4 0m-2 8v-1a2 2 0 0 1 2-2h2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold dark:text-white">Pull Request Deployments</div>
|
||||
</div>
|
||||
<div class="mt-1 text-base leading-7 text-gray-300">
|
||||
Automagically deploy new commits and pull requests separately to quickly
|
||||
review contributions and speed up your teamwork!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-20 text-xs">
|
||||
<span class="dark:text-warning">*</span> Some features are work in progress and will be available soon.
|
||||
</div> --}}
|
||||
</div>
|
||||
@isset($other)
|
||||
{{ $other }}
|
||||
@endisset
|
||||
@@ -1,7 +0,0 @@
|
||||
@props([
|
||||
'title' => 'Profile',
|
||||
'subtitle' => 'Your account preferences',
|
||||
'titleOnDesktop' => false,
|
||||
])
|
||||
|
||||
<x-dashboard.navbar section="profile" :title="$title" :subtitle="$subtitle" :titleOnDesktop="$titleOnDesktop" />
|
||||
@@ -1,149 +0,0 @@
|
||||
{{--
|
||||
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>
|
||||
@@ -1,22 +0,0 @@
|
||||
<div @class([
|
||||
'group flex min-w-0 items-center gap-3 rounded-[10px] border border-neutral-200 bg-white p-3 transition-[border-color,background-color,box-shadow] dark:border-white/[0.07] dark:bg-surface',
|
||||
'cursor-not-allowed opacity-60' => $upgrade,
|
||||
'hover:border-neutral-300 hover:bg-neutral-50 hover:shadow-sm dark:hover:border-white/[0.12] dark:hover:bg-white/[0.035]' => ! $upgrade,
|
||||
])>
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-neutral-100 text-black dark:bg-white/[0.06] dark:text-fg">
|
||||
{{ $logo }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-semibold text-black dark:text-fg">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if ($upgrade)
|
||||
<div class="mt-0.5 text-xs text-neutral-500 dark:text-fg-dim">{{ $upgrade }}</div>
|
||||
@else
|
||||
<div class="mt-0.5 line-clamp-2 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
{{ $description }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,281 +0,0 @@
|
||||
@props([
|
||||
'lastDeploymentInfo' => null,
|
||||
'lastDeploymentLink' => null,
|
||||
'resource' => null,
|
||||
'projects' => null,
|
||||
'environments' => null,
|
||||
])
|
||||
@php
|
||||
use App\Models\Project;
|
||||
|
||||
// Use passed props if available, otherwise query (backwards compatible)
|
||||
$projects = $projects ?? Project::ownedByCurrentTeamCached();
|
||||
$environments = $environments ?? $resource->environment->project
|
||||
->environments()
|
||||
->select('id', 'uuid', 'name', 'project_id')
|
||||
->with([
|
||||
'applications:id,uuid,name,environment_id',
|
||||
'services:id,uuid,name,environment_id',
|
||||
'postgresqls:id,uuid,name,environment_id',
|
||||
'redis:id,uuid,name,environment_id',
|
||||
'mongodbs:id,uuid,name,environment_id',
|
||||
'mysqls:id,uuid,name,environment_id',
|
||||
'mariadbs:id,uuid,name,environment_id',
|
||||
'keydbs:id,uuid,name,environment_id',
|
||||
'dragonflies:id,uuid,name,environment_id',
|
||||
'clickhouses:id,uuid,name,environment_id',
|
||||
'sqlites:id,uuid,name,environment_id',
|
||||
])
|
||||
->get();
|
||||
$currentProjectUuid = data_get($resource, 'environment.project.uuid');
|
||||
$currentEnvironmentUuid = data_get($resource, 'environment.uuid');
|
||||
$currentResourceUuid = data_get($resource, 'uuid');
|
||||
$resourceUuid = data_get($resource, 'uuid');
|
||||
$resourceType = $resource->getMorphClass();
|
||||
$isApplication = $resourceType === 'App\Models\Application';
|
||||
$isService = $resourceType === 'App\Models\Service';
|
||||
$isDatabase = str_contains($resourceType, 'Database') || str_contains($resourceType, 'Standalone');
|
||||
$hasMultipleServers = $isApplication && method_exists($resource, 'additional_servers') &&
|
||||
($resource->relationLoaded('additional_servers') ? $resource->additional_servers->count() > 0 : ($resource->additional_servers_count ?? 0) > 0);
|
||||
$serverName = $hasMultipleServers ? null : data_get($resource, 'destination.server.name');
|
||||
$routeParams = [
|
||||
'project_uuid' => $currentProjectUuid,
|
||||
'environment_uuid' => $currentEnvironmentUuid,
|
||||
];
|
||||
if ($isApplication) {
|
||||
$routeParams['application_uuid'] = $resourceUuid;
|
||||
} elseif ($isService) {
|
||||
$routeParams['service_uuid'] = $resourceUuid;
|
||||
} else {
|
||||
$routeParams['database_uuid'] = $resourceUuid;
|
||||
}
|
||||
@endphp
|
||||
<nav class="pt-2 pb-4 md:pb-10">
|
||||
<div class="flex min-w-0 flex-col gap-1 md:hidden">
|
||||
<div class="flex min-w-0 items-center text-xs text-neutral-400">
|
||||
<a class="min-w-0 truncate" {{ wireNavigate() }}
|
||||
href="{{ $isApplication
|
||||
? route('project.application.configuration', $routeParams)
|
||||
: ($isService
|
||||
? route('project.service.configuration', $routeParams)
|
||||
: route('project.database.configuration', $routeParams)) }}"
|
||||
title="{{ data_get($resource, 'name') }}{{ $serverName ? ' ('.$serverName.')' : '' }}">
|
||||
{{ data_get($resource, 'name') }}
|
||||
</a>
|
||||
</div>
|
||||
@if ($resource->getMorphClass() == 'App\Models\Service')
|
||||
<x-status.services :service="$resource" />
|
||||
@elseif ($isApplication)
|
||||
<x-status-summary :status="$resource->status" />
|
||||
@else
|
||||
<x-status.index :resource="$resource" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<ol class="hidden flex-wrap items-center gap-y-1 md:flex">
|
||||
<!-- Project Level -->
|
||||
<li class="inline-flex items-center" x-data="{ projectOpen: false, closeTimeout: null, toggle() { this.projectOpen = !this.projectOpen }, open() { clearTimeout(this.closeTimeout); this.projectOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.projectOpen = false }, 100) } }">
|
||||
<div class="flex items-center relative" @mouseenter="open()" @mouseleave="close()">
|
||||
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
|
||||
href="{{ route('project.show', ['project_uuid' => $currentProjectUuid]) }}">
|
||||
{{ data_get($resource, 'environment.project.name', 'Undefined Name') }}
|
||||
</a>
|
||||
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
|
||||
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-down': projectOpen }" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Project Dropdown -->
|
||||
<div x-show="projectOpen" @click.outside="close()" x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute z-20 top-full mt-1 w-56 -ml-2 bg-white dark:bg-coolgray-100 rounded-md shadow-[var(--shadow-dropdown)] py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
|
||||
@foreach ($projects as $project)
|
||||
<a href="{{ route('project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}
|
||||
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $project->uuid === $currentProjectUuid ? 'dark:text-warning font-semibold' : '' }}"
|
||||
title="{{ $project->name }}">
|
||||
{{ $project->name }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Environment Level -->
|
||||
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, closeTimeout: null, envTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null; } }, open() { clearTimeout(this.closeTimeout); this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false; this.activeEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout); clearTimeout(this.envTimeout); this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null; }, 100) } }">
|
||||
<div class="flex items-center relative" @mouseenter="open()"
|
||||
@mouseleave="close()">
|
||||
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
|
||||
href="{{ route('project.resource.index', [
|
||||
'environment_uuid' => $currentEnvironmentUuid,
|
||||
'project_uuid' => $currentProjectUuid,
|
||||
]) }}">
|
||||
{{ data_get($resource, 'environment.name') }}
|
||||
</a>
|
||||
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
|
||||
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-down': envOpen }" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Environment Dropdown -->
|
||||
<div x-show="envOpen" @click.outside="close()" x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute z-20 top-full mt-1 left-0 sm:left-auto max-w-[calc(100vw-1rem)]"
|
||||
x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
|
||||
<!-- Environment List -->
|
||||
<div
|
||||
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-[var(--shadow-dropdown)] py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
|
||||
@foreach ($environments as $environment)
|
||||
@php
|
||||
$envDatabases = collect()
|
||||
->merge($environment->postgresqls ?? collect())
|
||||
->merge($environment->redis ?? collect())
|
||||
->merge($environment->mongodbs ?? collect())
|
||||
->merge($environment->mysqls ?? collect())
|
||||
->merge($environment->mariadbs ?? collect())
|
||||
->merge($environment->keydbs ?? collect())
|
||||
->merge($environment->dragonflies ?? collect())
|
||||
->merge($environment->clickhouses ?? collect())
|
||||
->merge($environment->sqlites ?? collect());
|
||||
$envResources = collect()
|
||||
->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
|
||||
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
|
||||
->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]))
|
||||
->sortBy(fn($item) => strtolower($item['resource']->name));
|
||||
@endphp
|
||||
<div @mouseenter="openEnv('{{ $environment->uuid }}'); envPositions['{{ $environment->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
|
||||
@mouseleave="closeEnv()">
|
||||
<a href="{{ route('project.resource.index', [
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'project_uuid' => $currentProjectUuid,
|
||||
]) }}" {{ wireNavigate() }}
|
||||
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $environment->uuid === $currentEnvironmentUuid ? 'dark:text-warning font-semibold' : '' }}"
|
||||
title="{{ $environment->name }}">
|
||||
<span class="truncate">{{ $environment->name }}</span>
|
||||
@if ($envResources->count() > 0)
|
||||
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4"
|
||||
d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
<div class="border-t border-neutral-200 dark:border-coolgray-200 mt-1 pt-1">
|
||||
<a href="{{ route('project.show', ['project_uuid' => $currentProjectUuid]) }}" {{ wireNavigate() }}
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z">
|
||||
</path>
|
||||
</svg>
|
||||
Create / Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resources Sub-dropdown (2nd level) -->
|
||||
@foreach ($environments as $environment)
|
||||
@php
|
||||
$envDatabases = collect()
|
||||
->merge($environment->postgresqls ?? collect())
|
||||
->merge($environment->redis ?? collect())
|
||||
->merge($environment->mongodbs ?? collect())
|
||||
->merge($environment->mysqls ?? collect())
|
||||
->merge($environment->mariadbs ?? collect())
|
||||
->merge($environment->keydbs ?? collect())
|
||||
->merge($environment->dragonflies ?? collect())
|
||||
->merge($environment->clickhouses ?? collect())
|
||||
->merge($environment->sqlites ?? collect());
|
||||
$envResources = collect()
|
||||
->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
|
||||
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
|
||||
->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
|
||||
@endphp
|
||||
@if ($envResources->count() > 0)
|
||||
<div x-show="activeEnv === '{{ $environment->uuid }}'" x-cloak
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
@mouseenter="openEnv('{{ $environment->uuid }}')" @mouseleave="closeEnv()"
|
||||
:style="'position: absolute; left: 100%; top: ' + (envPositions['{{ $environment->uuid }}'] || 0) + 'px; z-index: 30;'"
|
||||
class="flex flex-col sm:flex-row items-start pl-1">
|
||||
<div
|
||||
class="relative w-56 bg-white dark:bg-coolgray-100 rounded-md shadow-[var(--shadow-dropdown)] py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
|
||||
@foreach ($envResources as $envResource)
|
||||
@php
|
||||
$resType = $envResource['type'];
|
||||
$res = $envResource['resource'];
|
||||
$resRoute = match ($resType) {
|
||||
'application' => route('project.application.configuration', [
|
||||
'project_uuid' => $currentProjectUuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'application_uuid' => $res->uuid,
|
||||
]),
|
||||
'service' => route('project.service.configuration', [
|
||||
'project_uuid' => $currentProjectUuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'service_uuid' => $res->uuid,
|
||||
]),
|
||||
'database' => route('project.database.configuration', [
|
||||
'project_uuid' => $currentProjectUuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $res->uuid,
|
||||
]),
|
||||
};
|
||||
$isCurrentResource = $res->uuid === $currentResourceUuid;
|
||||
@endphp
|
||||
<a href="{{ $resRoute }}" {{ wireNavigate() }}
|
||||
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $isCurrentResource ? 'dark:text-warning font-semibold' : '' }}"
|
||||
title="{{ $res->name }}">
|
||||
{{ $res->name }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Resource Level -->
|
||||
<li class="inline-flex items-center mr-2">
|
||||
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
|
||||
href="{{ $isApplication
|
||||
? route('project.application.configuration', $routeParams)
|
||||
: ($isService
|
||||
? route('project.service.configuration', $routeParams)
|
||||
: route('project.database.configuration', $routeParams)) }}"
|
||||
title="{{ data_get($resource, 'name') }}{{ $serverName ? ' ('.$serverName.')' : '' }}">
|
||||
{{ data_get($resource, 'name') }}@if($serverName) <span class="text-xs text-neutral-400">({{ $serverName }})</span>@endif
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Current Section Status -->
|
||||
@if ($resource->getMorphClass() == 'App\Models\Service')
|
||||
<x-status.services :service="$resource" />
|
||||
@elseif ($isApplication)
|
||||
<x-status-summary :status="$resource->status" />
|
||||
@else
|
||||
<x-status.index :resource="$resource" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
@endif
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.rotate-down {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.transition-transform {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
@props([
|
||||
'title' => 'Keys & Tokens',
|
||||
'subtitle' => 'SSH keys, cloud tokens, and API access',
|
||||
// Family index/list views hide the H1 only at xl+ (topbar + tabs cover context).
|
||||
// Detail views pass true so the resource name stays visible.
|
||||
'titleOnDesktop' => false,
|
||||
])
|
||||
|
||||
<x-dashboard.navbar section="security" :title="$title" :subtitle="$subtitle" :titleOnDesktop="$titleOnDesktop">
|
||||
@isset($titleActions)
|
||||
<x-slot:titleActions>
|
||||
{{ $titleActions }}
|
||||
</x-slot:titleActions>
|
||||
@endisset
|
||||
@isset($actions)
|
||||
<x-slot:actions>
|
||||
{{ $actions }}
|
||||
</x-slot:actions>
|
||||
@endisset
|
||||
</x-dashboard.navbar>
|
||||
@@ -1,38 +0,0 @@
|
||||
@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>
|
||||
@@ -1,49 +0,0 @@
|
||||
@php
|
||||
$proxyMenuItems = [
|
||||
[
|
||||
'label' => 'Configuration',
|
||||
'route' => 'server.proxy',
|
||||
'active' => request()->routeIs('server.proxy'),
|
||||
'icon' => 'settings',
|
||||
],
|
||||
[
|
||||
'label' => 'Dynamic Configurations',
|
||||
'route' => 'server.proxy.dynamic-confs',
|
||||
'active' => request()->routeIs('server.proxy.dynamic-confs'),
|
||||
'icon' => 'sliders',
|
||||
'visible' => $server->proxySet(),
|
||||
],
|
||||
[
|
||||
'label' => 'Logs',
|
||||
'route' => 'server.proxy.logs',
|
||||
'active' => request()->routeIs('server.proxy.logs'),
|
||||
'icon' => 'file-content',
|
||||
'visible' => $server->proxySet(),
|
||||
'navigate' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$proxyMenuItems = array_values(array_filter(
|
||||
$proxyMenuItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Proxy sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Proxy</div>
|
||||
@foreach ($proxyMenuItems as $menuItem)
|
||||
<a wire:key="server-proxy-link-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -1,36 +0,0 @@
|
||||
@php
|
||||
$securityMenuItems = [
|
||||
[
|
||||
'label' => 'Server Patching',
|
||||
'route' => 'server.security.patches',
|
||||
'active' => request()->routeIs('server.security.patches'),
|
||||
'icon' => 'bandage',
|
||||
],
|
||||
[
|
||||
'label' => 'Terminal Access',
|
||||
'route' => 'server.security.terminal-access',
|
||||
'active' => request()->routeIs('server.security.terminal-access'),
|
||||
'icon' => 'browser-terminal',
|
||||
'navigate' => false,
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Server security sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Security</div>
|
||||
@foreach ($securityMenuItems as $menuItem)
|
||||
<a wire:key="server-security-link-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -1,37 +0,0 @@
|
||||
@php
|
||||
$sentinelMenuItems = [
|
||||
[
|
||||
'label' => 'Configuration',
|
||||
'route' => 'server.sentinel',
|
||||
'active' => request()->routeIs('server.sentinel'),
|
||||
'icon' => 'settings',
|
||||
],
|
||||
[
|
||||
'label' => 'Logs',
|
||||
'route' => 'server.sentinel.logs',
|
||||
'active' => request()->routeIs('server.sentinel.logs'),
|
||||
'icon' => 'file-content',
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
@can('viewSentinel', $server)
|
||||
<nav aria-label="Sentinel sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Sentinel</div>
|
||||
@foreach ($sentinelMenuItems as $menuItem)
|
||||
<a wire:key="server-sentinel-link-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
])
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
@endcan
|
||||
</aside>
|
||||
@@ -1,49 +0,0 @@
|
||||
@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
|
||||
@@ -1,50 +0,0 @@
|
||||
@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
|
||||
@@ -1,20 +0,0 @@
|
||||
@props([
|
||||
'title' => 'Settings',
|
||||
'subtitle' => 'Instance configuration and maintenance',
|
||||
])
|
||||
|
||||
{{-- Same 1180px shell as the workspace. Title hidden only at xl+ (full desktop). --}}
|
||||
<div class="mx-auto w-full max-w-none">
|
||||
<x-dashboard.navbar section="settings" :title="$title" :subtitle="$subtitle" :titleOnDesktop="false">
|
||||
@isset($titleActions)
|
||||
<x-slot:titleActions>
|
||||
{{ $titleActions }}
|
||||
</x-slot:titleActions>
|
||||
@endisset
|
||||
@isset($actions)
|
||||
<x-slot:actions>
|
||||
{{ $actions }}
|
||||
</x-slot:actions>
|
||||
@endisset
|
||||
</x-dashboard.navbar>
|
||||
</div>
|
||||
@@ -1,57 +0,0 @@
|
||||
@php
|
||||
$settingsMenuItems = [
|
||||
[
|
||||
'label' => 'General',
|
||||
'route' => 'settings.index',
|
||||
'active' => $activeMenu === 'general',
|
||||
'icon' => 'settings',
|
||||
],
|
||||
[
|
||||
'label' => 'Advanced',
|
||||
'route' => 'settings.advanced',
|
||||
'active' => $activeMenu === 'advanced',
|
||||
'icon' => 'grid',
|
||||
],
|
||||
[
|
||||
'label' => 'Authentication',
|
||||
'route' => 'settings.oauth',
|
||||
'active' => $activeMenu === 'oauth',
|
||||
'icon' => 'keys',
|
||||
],
|
||||
[
|
||||
'label' => 'Transactional Email',
|
||||
'route' => 'settings.email',
|
||||
'active' => $activeMenu === 'email',
|
||||
'icon' => 'notifications',
|
||||
],
|
||||
[
|
||||
'label' => 'Instance Backup',
|
||||
'route' => 'settings.backup',
|
||||
'active' => $activeMenu === 'backup',
|
||||
'icon' => 'database',
|
||||
],
|
||||
[
|
||||
'label' => 'Updates',
|
||||
'route' => 'settings.updates',
|
||||
'active' => $activeMenu === 'updates',
|
||||
'icon' => 'refresh3',
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Configuration sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Configuration</div>
|
||||
@foreach ($settingsMenuItems as $menuItem)
|
||||
<a @class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
])
|
||||
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -1,68 +0,0 @@
|
||||
@props(['closeWithX' => false, 'fullScreen' => false])
|
||||
{{-- `contents` so event-only shells (no trigger in the default slot) do not leave a
|
||||
ghost layout box above layer-2 tabs. Trigger buttons in the slot still flow
|
||||
into the parent as if unwrapped. --}}
|
||||
<div x-data="{
|
||||
slideOverOpen: false
|
||||
}"
|
||||
x-init="$watch('slideOverOpen', value => {
|
||||
if (!value) {
|
||||
$dispatch('slideOverClosed')
|
||||
}
|
||||
})"
|
||||
{{ $attributes->merge(['class' => 'contents']) }}>
|
||||
{{ $slot }}
|
||||
<template x-teleport="body">
|
||||
<div x-show="slideOverOpen" @if (!$closeWithX) @keydown.window.escape="slideOverOpen=false" @endif
|
||||
class="relative z-99 ">
|
||||
<div x-show="slideOverOpen" @if (!$closeWithX) @click="slideOverOpen = false" @endif
|
||||
x-transition:enter="transition-opacity ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition-opacity ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 dark:bg-black/60 backdrop-blur-xs"></div>
|
||||
<div class="fixed inset-0 overflow-hidden">
|
||||
<div class="absolute inset-0 overflow-hidden ">
|
||||
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<div x-show="slideOverOpen"
|
||||
@if (!$closeWithX) @click.away="slideOverOpen = false" @endif
|
||||
x-transition:enter="transform transition ease-[cubic-bezier(0.32,0.72,0,1)] duration-200 sm:duration-300"
|
||||
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transform transition ease-in duration-200"
|
||||
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
|
||||
@class([
|
||||
'max-w-xl w-screen' => !$fullScreen,
|
||||
'max-w-4xl w-screen' => $fullScreen,
|
||||
])>
|
||||
<div
|
||||
class="flex flex-col h-full py-6 overflow-hidden border-l shadow-modal bg-neutral-50 dark:bg-base dark:border-neutral-800 border-neutral-200">
|
||||
<div class="px-4 pb-4 sm:px-5">
|
||||
<div class="flex items-start justify-between pb-1">
|
||||
<h2 class="text-2xl leading-6" id="slide-over-title">
|
||||
{{ $title }}
|
||||
</h2>
|
||||
<div class="flex items-center h-auto ml-3">
|
||||
<button @click="slideOverOpen=false"
|
||||
class="absolute cursor-pointer top-0 right-0 z-30 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 px-4 mt-5 overflow-auto sm:px-5 scrollbar">
|
||||
<div class="absolute inset-0 px-4 sm:px-5">
|
||||
{{ $content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -1,19 +0,0 @@
|
||||
@props([
|
||||
'status' => 'Degraded',
|
||||
])
|
||||
@php
|
||||
if (str($status)->contains('(')) {
|
||||
$displayStatus = $status;
|
||||
$healthStatus = null;
|
||||
} elseif (str($status)->contains(':') && ! str($status)->startsWith('Proxy')) {
|
||||
$parts = explode(':', $status);
|
||||
$displayStatus = str($parts[0])->headline()->value();
|
||||
$healthStatus = $parts[1] ?? null;
|
||||
} else {
|
||||
$displayStatus = str($status)->headline()->value();
|
||||
$healthStatus = null;
|
||||
}
|
||||
|
||||
$badgeStatus = $healthStatus ? "{$displayStatus} ({$healthStatus})" : $displayStatus;
|
||||
@endphp
|
||||
<x-status-badge status="{{ $badgeStatus }}" type="warning" />
|
||||
@@ -1,33 +0,0 @@
|
||||
@props([
|
||||
'title' => null,
|
||||
'lastDeploymentLink' => null,
|
||||
'resource' => null,
|
||||
'showRefreshButton' => true,
|
||||
])
|
||||
@php
|
||||
$stoppedAfterRestartLimit = $resource && method_exists($resource, 'stoppedAfterRestartLimit') && $resource->stoppedAfterRestartLimit();
|
||||
@endphp
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
@if (str($resource->status)->startsWith('running'))
|
||||
<x-status.running :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
@elseif(str($resource->status)->startsWith('degraded'))
|
||||
<x-status.degraded :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
@elseif(str($resource->status)->startsWith('restarting') || str($resource->status)->startsWith('starting'))
|
||||
<x-status.restarting :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
@else
|
||||
<x-status.stopped :status="$resource->status" />
|
||||
@endif
|
||||
@if (isset($resource->restart_count) && $resource->restart_count > 0 && (!str($resource->status)->startsWith('exited') || $stoppedAfterRestartLimit))
|
||||
<x-status-badge status="{{ $resource->restart_count }}x restarts" type="warning"
|
||||
title="Container has restarted {{ $resource->restart_count }} time{{ $resource->restart_count > 1 ? 's' : '' }}. Last restart: {{ $resource->last_restart_at?->diffForHumans() }}" />
|
||||
@endif
|
||||
@if ($stoppedAfterRestartLimit)
|
||||
<x-application.restart-limit-warning :application="$resource" />
|
||||
@endif
|
||||
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
|
||||
<x-status-badge as="button" wire:target="manualCheckStatus" wire:loading.attr="disabled"
|
||||
wire:click='manualCheckStatus' status="Refresh" type="neutral" title="Refresh Status"
|
||||
aria-label="Refresh status"
|
||||
class="min-w-[4.5rem] justify-center cursor-pointer border-transparent hover:bg-neutral-200 disabled:cursor-wait disabled:opacity-70 dark:hover:bg-coolgray-300" />
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,22 +0,0 @@
|
||||
@php
|
||||
$displayStatus = formatContainerStatus($complexStatus);
|
||||
@endphp
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
@if (str($displayStatus)->lower()->contains('running'))
|
||||
<x-status.running :status="$displayStatus" />
|
||||
@elseif(str($displayStatus)->lower()->contains('starting'))
|
||||
<x-status.restarting :status="$displayStatus" />
|
||||
@elseif(str($displayStatus)->lower()->contains('restarting'))
|
||||
<x-status.restarting :status="$displayStatus" />
|
||||
@elseif(str($displayStatus)->lower()->contains('degraded'))
|
||||
<x-status.degraded :status="$displayStatus" />
|
||||
@else
|
||||
<x-status.stopped :status="$displayStatus" />
|
||||
@endif
|
||||
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
|
||||
<x-status-badge as="button" wire:target="manualCheckStatus" wire:loading.attr="disabled"
|
||||
wire:click='manualCheckStatus' status="Refresh" type="neutral" title="Refresh Status"
|
||||
aria-label="Refresh status"
|
||||
class="min-w-[4.5rem] justify-center cursor-pointer border-transparent hover:bg-neutral-200 disabled:cursor-wait disabled:opacity-70 dark:hover:bg-coolgray-300" />
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,30 +0,0 @@
|
||||
@props([
|
||||
'title' => 'Team',
|
||||
'subtitle' => 'Members, roles, and team settings',
|
||||
// Hide family H1 only at xl+; keep create in the layer-2 bar.
|
||||
'titleOnDesktop' => false,
|
||||
])
|
||||
|
||||
<x-dashboard.navbar section="team" :title="$title" :subtitle="$subtitle" :titleOnDesktop="$titleOnDesktop">
|
||||
@isset($titleActions)
|
||||
<x-slot:titleActions>
|
||||
{{ $titleActions }}
|
||||
</x-slot:titleActions>
|
||||
@endisset
|
||||
<x-slot:actions>
|
||||
@isset($actions)
|
||||
{{ $actions }}
|
||||
@else
|
||||
<x-modal-input title="New Team">
|
||||
<x-slot:content>
|
||||
<button type="button"
|
||||
class="button button-highlighted">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
New team
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:team.create />
|
||||
</x-modal-input>
|
||||
@endisset
|
||||
</x-slot:actions>
|
||||
</x-dashboard.navbar>
|
||||
@@ -1,11 +0,0 @@
|
||||
<div class="pt-4">
|
||||
@if (isset($link))
|
||||
Create a new one
|
||||
<a href="{{ $link }}" class="underline dark:text-warning">
|
||||
here.
|
||||
</a>
|
||||
{{-- @else
|
||||
Use the magic
|
||||
bar (press <span class="kbd-custom">/</span>) to create a new one. --}}
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
@props([
|
||||
'destination',
|
||||
'title' => null,
|
||||
'subtitle' => null,
|
||||
])
|
||||
|
||||
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
||||
<x-dashboard.navbar section="destination" :parameters="['destination_uuid' => $destination->uuid]"
|
||||
:title="$title" :subtitle="$subtitle" :titleOnDesktop="true" />
|
||||
@elseif (filled($title))
|
||||
<header class="mb-8 min-w-0">
|
||||
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $title }}</h1>
|
||||
@if (filled($subtitle))
|
||||
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">{{ $subtitle }}</p>
|
||||
@endif
|
||||
</header>
|
||||
@endif
|
||||
@@ -1,8 +0,0 @@
|
||||
<div>
|
||||
<h2>Destination</h2>
|
||||
<div class="">The destination server / network where your application will be deployed to.</div>
|
||||
<div class="py-4 ">
|
||||
<p>Server: {{ data_get($destination, 'server.name') }}</p>
|
||||
<p>Destination Network: {{ $destination->network }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,3 +0,0 @@
|
||||
<x-forms.button wire:click="backupNow"
|
||||
:disabled="! str($backup->database->status)->startsWith('running')"
|
||||
:tooltip="! str($backup->database->status)->startsWith('running') ? 'The database must be running to start a backup.' : null">Back up now</x-forms.button>
|
||||
@@ -1 +0,0 @@
|
||||
<x-forms.button wire:click='createEmptyProject'>Empty Project</x-forms.button>
|
||||
@@ -1,84 +0,0 @@
|
||||
<div class="w-full">
|
||||
<form wire:submit.prevent='submit' class="flex w-full flex-col gap-4">
|
||||
@if($requiredPort)
|
||||
<x-callout type="info" title="Required Port: {{ $requiredPort }}" class="mb-2">
|
||||
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly. All domains must include this port number (or any other port if you know what you're doing).
|
||||
<br><br>
|
||||
<strong>Example:</strong> https://app.coolify.io:{{ $requiredPort }},https://www.app.coolify.io:{{ $requiredPort }}
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
<x-forms.domain-chips model="fqdn" label="Domains"
|
||||
:can-update="auth()->user()->can('update', $application)"
|
||||
:disabled="! auth()->user()->can('update', $application)" />
|
||||
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button canGate="update" :canResource="$application" type="submit">Save domain</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal" confirmAction="confirmDomainUsage">
|
||||
<x-slot:consequences>
|
||||
<ul class="mt-2 ml-4 list-disc">
|
||||
<li>Only one service will be accessible at this domain</li>
|
||||
<li>The routing behavior will be unpredictable</li>
|
||||
<li>You may experience service disruptions</li>
|
||||
<li>SSL certificates might not work correctly</li>
|
||||
</ul>
|
||||
</x-slot:consequences>
|
||||
</x-domain-conflict-modal>
|
||||
|
||||
@if ($showPortWarningModal)
|
||||
<div x-data="{ modalOpen: true }" x-init="$nextTick(() => { modalOpen = true })"
|
||||
@keydown.escape.window="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
:class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
|
||||
<div x-show="modalOpen" class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Use a different port?</h3>
|
||||
<button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="flex size-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Port requirement" class="mb-4">
|
||||
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
|
||||
One or more of your domains use a different port, or none.
|
||||
</x-callout>
|
||||
|
||||
<x-callout type="danger" title="What will happen if you continue?" class="mb-4">
|
||||
<ul class="mt-2 ml-4 list-disc">
|
||||
<li>The service may become unreachable</li>
|
||||
<li>The proxy may not be able to route traffic correctly</li>
|
||||
<li>Environment variables may not be generated properly</li>
|
||||
<li>The service may fail to start or function</li>
|
||||
</ul>
|
||||
</x-callout>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="w-auto">
|
||||
Keep required port
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="confirmRemovePort" @click="modalOpen = false" class="w-auto"
|
||||
isError>
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,6 +0,0 @@
|
||||
<form wire:submit="uploadConfig" class="flex flex-col gap-2 w-full">
|
||||
<x-forms.textarea id="config" monacoEditorLanguage="json" useMonacoEditor />
|
||||
<x-forms.button type="submit">
|
||||
Upload
|
||||
</x-forms.button>
|
||||
</form>
|
||||
@@ -1,48 +0,0 @@
|
||||
<div wire:poll.2000ms="getDeployments" wire:init="getDeployments">
|
||||
<x-application.settings-section title="Active deployments"
|
||||
description="Deployments currently running for resources with this tag." flush>
|
||||
@if (collect($deploymentsPerTagPerServer)->flatten(1)->isEmpty())
|
||||
<x-empty title="No active deployments"
|
||||
description="Queued and running deployments will appear here automatically."
|
||||
icon-name="refresh" size="sm" />
|
||||
@else
|
||||
<div class="data-table w-full">
|
||||
<div
|
||||
class="grid grid-cols-[minmax(0,1fr)_minmax(8rem,.55fr)_8rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
|
||||
<span>Resource</span>
|
||||
<span>Server</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
|
||||
@foreach ($deploymentsPerTagPerServer as $serverName => $deployments)
|
||||
@foreach ($deployments as $deployment)
|
||||
@php
|
||||
$status = data_get($deployment, 'status');
|
||||
$statusType = $status === 'in_progress' ? 'warning' : 'neutral';
|
||||
@endphp
|
||||
<a {{ wireNavigate() }} href="{{ data_get($deployment, 'deployment_url') }}"
|
||||
class="grid min-h-14 grid-cols-[minmax(0,1fr)_minmax(8rem,.55fr)_8rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<span class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim">
|
||||
<x-reicon name="refresh" class="size-4" />
|
||||
</span>
|
||||
<span class="truncate text-[13px] font-semibold text-black dark:text-fg">
|
||||
{{ data_get($deployment, 'application_name') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $serverName }}</span>
|
||||
<x-status-badge :status="str($status)->headline()" :type="$statusType" />
|
||||
</a>
|
||||
@endforeach
|
||||
@endforeach
|
||||
|
||||
<footer
|
||||
class="flex min-h-11 items-center border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
|
||||
{{ collect($deploymentsPerTagPerServer)->flatten(1)->count() }}
|
||||
{{ Str::plural('deployment', collect($deploymentsPerTagPerServer)->flatten(1)->count()) }}
|
||||
</footer>
|
||||
</div>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Advanced navigation and action menus must share the same reicon ("grid").
|
||||
*/
|
||||
test('advanced sidebar and configuration menus use the grid icon', function () {
|
||||
$files = [
|
||||
resource_path('views/components/settings/layout.blade.php'),
|
||||
resource_path('views/components/server/sidebar.blade.php'),
|
||||
resource_path('views/components/service-database/sidebar.blade.php'),
|
||||
resource_path('views/livewire/project/service/index.blade.php'),
|
||||
resource_path('views/livewire/project/application/configuration.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($files as $path) {
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)->toContain('Advanced');
|
||||
|
||||
// PHP menu arrays: label Advanced paired with icon grid
|
||||
if (str_contains($contents, "'label' => 'Advanced'") || str_contains($contents, "'Advanced' =>")) {
|
||||
expect($contents)
|
||||
->toMatch("/'label'\\s*=>\\s*'Advanced'[\\s\\S]{0,160}?'icon'\\s*=>\\s*'grid'|'Advanced'\\s*=>\\s*'grid'/")
|
||||
->not->toMatch("/'Advanced'\\s*=>\\s*'(?!grid)[^']+'/");
|
||||
}
|
||||
|
||||
// Inline blade markup: reicon grid near Advanced label
|
||||
if (str_contains($contents, 'menu-item-label">Advanced')) {
|
||||
expect($contents)
|
||||
->toMatch('/name="grid"[\\s\\S]{0,120}menu-item-label">Advanced/')
|
||||
->not->toMatch('/name="(?!grid)[^"]+"[\\s\\S]{0,120}menu-item-label">Advanced/');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('advanced action dropdown menus use the grid icon', function () {
|
||||
$files = [
|
||||
resource_path('views/components/applications/advanced.blade.php'),
|
||||
resource_path('views/components/services/advanced.blade.php'),
|
||||
resource_path('views/components/server/advanced.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($files as $path) {
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)
|
||||
->toContain('Advanced')
|
||||
->toContain('name="grid"')
|
||||
->not->toContain('name="sliders"')
|
||||
->not->toContain("name='sliders'");
|
||||
}
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('shows API token permissions in a dropdown and keeps non-root options rendered', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php'));
|
||||
$permissionsSection = str($view)->between(
|
||||
'<h4 class="text-[12px] font-semibold text-black dark:text-fg">Permissions</h4>',
|
||||
'</x-application.settings-section>'
|
||||
)->toString();
|
||||
|
||||
expect($permissionsSection)
|
||||
->toContain('permissionsOpen')
|
||||
->toContain('Selected permissions')
|
||||
->toContain('<x-forms.checkbox')
|
||||
->toContain("in_array('root', \$permissions)")
|
||||
->toContain('Read sensitive data')
|
||||
->not->toContain('<input type="checkbox" value="root"')
|
||||
->not->toContain("@if (!in_array('root', \$permissions))");
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('api tokens revoke action is text-only without a trash icon', function () {
|
||||
$blade = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php'));
|
||||
|
||||
expect($blade)
|
||||
->toContain('Revoke')
|
||||
->not->toMatch('/name="trash"[\s\S]{0,80}Revoke|Revoke[\s\S]{0,80}name="trash"/');
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('custom color theme is available and applied across theme controls', function () {
|
||||
$appearance = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
|
||||
$accountMenu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
|
||||
$component = file_get_contents(resource_path('views/components/theme-controls.blade.php'));
|
||||
$picker = file_get_contents(resource_path('views/components/theme-controls/picker.blade.php'));
|
||||
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
|
||||
$deploymentLogs = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
$serverTiming = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
|
||||
$terminal = file_get_contents(resource_path('js/terminal.js'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
expect($appearance)
|
||||
->toContain('<x-theme-controls variant="full"')
|
||||
->and($accountMenu)
|
||||
->toContain('<x-theme-controls variant="menu"')
|
||||
->and($component)
|
||||
->toContain("['value' => 'custom', 'label' => 'Custom'")
|
||||
->toContain('<x-theme-controls.picker')
|
||||
->and($picker)
|
||||
->toContain('type="color"')
|
||||
->toContain('@input="previewThemeColor($event.target.value)"')
|
||||
->toContain('@change="saveThemeColor($event.target.value)"')
|
||||
->toContain('aria-label="Custom theme color"')
|
||||
->toContain('setCustomMode')
|
||||
->and($layout)
|
||||
->toContain('window.themeControls')
|
||||
->toContain('requestAnimationFrame(() =>')
|
||||
->toContain("localStorage.setItem('themeColor', color)")
|
||||
->toContain("this.theme === 'custom'")
|
||||
->and($layout)
|
||||
->toContain("t === 'custom'")
|
||||
->toContain("localStorage.themeColor || '#6b16ed'")
|
||||
->toContain('--theme-accent-foreground')
|
||||
->toContain('window.applyStoredTheme')
|
||||
->toContain("document.addEventListener('livewire:navigated', window.applyStoredTheme)")
|
||||
->and($styles)
|
||||
->toContain('html[data-theme="custom"]')
|
||||
->toContain('--theme-base-color: oklch(49.65% 0.2709 289.33);')
|
||||
->toContain('--theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, oklch(100% 0 0));')
|
||||
->toContain('--color-accent: var(--theme-bright-color);')
|
||||
->toContain('--color-coollabs: var(--theme-bright-color);')
|
||||
->toContain('--color-warning: var(--theme-bright-color);')
|
||||
->toContain('--color-accent-foreground: var(--theme-accent-foreground);')
|
||||
->toContain('--color-fg-dim: oklch(91.08% 0.0157 306.4);')
|
||||
->toContain('--color-fg-faint: oklch(87.24% 0.0188 306.63);')
|
||||
->toContain('html[data-theme="custom"] .control-selected')
|
||||
->toContain('--theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground));')
|
||||
->toContain('--theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, oklch(44.19% 0.0146 285.79));')
|
||||
->toContain('--theme-fg-on-surface: oklch(from var(--theme-base-color) clamp(0.72, l, 0.86) min(c, 0.13) h);')
|
||||
->toContain('--theme-placeholder-color: oklch(from var(--theme-base-color) 0.62 min(c, 0.03) h);')
|
||||
->toContain('html[data-theme="custom"] *')
|
||||
->toContain('scrollbar-color: var(--theme-scrollbar-thumb) var(--color-panel);')
|
||||
->toContain('html[data-theme="custom"] *::-webkit-scrollbar-thumb')
|
||||
->toContain('html[data-theme="custom"] .application-settings-navigation')
|
||||
->toContain('scrollbar-gutter: stable;')
|
||||
->toContain('--color-panel: color-mix(in oklab, var(--theme-base-color) 14%, oklch(15.48% 0.0021 286.15));')
|
||||
->toContain('--color-surface: color-mix(in oklab, var(--theme-base-color) 18%, oklch(17.35% 0.0020 286.18));')
|
||||
->toContain('--coollabs-elevated: var(--color-surface);')
|
||||
->toContain('--coollabs-recessed: var(--color-raised);')
|
||||
->toContain('--coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, oklch(17.81% 0.0020 286.19));')
|
||||
->toContain('--color-content-surface: var(--coollabs-base);')
|
||||
->toContain('--coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, oklch(20.99% 0.0039 286.06));')
|
||||
->toContain('--color-nav-text: oklch(91.08% 0.0157 306.4);')
|
||||
->toContain('--color-nav-muted: oklch(87.24% 0.0188 306.63);')
|
||||
->toContain('--color-log: color-mix(in oklab, var(--theme-base-color) 20%, oklch(13.49% 0.0024 286.07));')
|
||||
->toContain('--color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, oklch(17.35% 0.0020 286.18));')
|
||||
->toContain('html[data-theme="custom"] .logs-viewer-toolbar')
|
||||
->toContain('html[data-theme="custom"] .logs-viewer-timestamp')
|
||||
->toContain('html[data-theme="custom"] #nprogress .bar')
|
||||
->toContain('background: var(--theme-bright-color) !important;')
|
||||
->toContain('html[data-theme="custom"] #nprogress .spinner-icon')
|
||||
->toContain('html[data-theme="custom"] .loading-indicator')
|
||||
->toContain('html[data-theme="custom"] .data-table')
|
||||
->toContain('html[data-theme="custom"] [class~="dark:bg-white/[0.025]"]')
|
||||
->toContain('html[data-theme="custom"] [class~="dark:bg-surface"]')
|
||||
->toContain('background-color: var(--color-content-surface) !important;')
|
||||
->toContain('.dark [class~="dark:border-white/[0.08]"]')
|
||||
->toContain('.dark [class~="dark:border-white/[0.06]"]')
|
||||
->toContain('border-color: var(--coollabs-hairline) !important;')
|
||||
->toContain('html[data-theme="custom"] input::placeholder')
|
||||
->toContain('color: var(--theme-placeholder-color) !important;')
|
||||
->toMatch('/html\[data-theme="custom"\] input::placeholder,[^{]+\{[^}]*opacity: 1;/s')
|
||||
->toContain('html[data-theme="custom"] input:read-only')
|
||||
->and($deploymentLogs)
|
||||
->toContain('dark:bg-log')
|
||||
->not->toContain('dark:bg-[#0d0d0d]')
|
||||
->and($serverTiming)
|
||||
->toContain('html[data-theme="custom"] #server-timing-hud')
|
||||
->toContain('--sth-background: var(--color-surface);')
|
||||
->toContain('--sth-livewire: var(--theme-bright-color);')
|
||||
->and($utilities)
|
||||
->toContain('text-nav-text')
|
||||
->toContain('text-nav-muted')
|
||||
->toContain('text-nav-active')
|
||||
->and($terminal)
|
||||
->toContain("dataset.theme === 'custom'")
|
||||
->toContain("localStorage.getItem('themeColor')")
|
||||
->toContain("attributeFilter: ['class', 'data-theme', 'style']")
|
||||
->and($styles)
|
||||
->toContain('html[data-theme="custom"] .application-console-shell[data-console-theme="system"]')
|
||||
->toContain('--console-theme-background: var(--color-log);')
|
||||
->toContain('--console-theme-border: var(--coollabs-line);');
|
||||
});
|
||||
|
||||
test('navigation colors meet WCAG AA contrast requirements', function () {
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
$contrastRatio = function (string $foreground, string $background): float {
|
||||
$luminance = function (string $color): float {
|
||||
$channels = array_map(
|
||||
fn (string $channel): float => hexdec($channel) / 255,
|
||||
str_split(ltrim($color, '#'), 2),
|
||||
);
|
||||
|
||||
$channels = array_map(
|
||||
fn (float $channel): float => $channel <= 0.04045
|
||||
? $channel / 12.92
|
||||
: (($channel + 0.055) / 1.055) ** 2.4,
|
||||
$channels,
|
||||
);
|
||||
|
||||
return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
|
||||
};
|
||||
|
||||
[$lighter, $darker] = collect([$luminance($foreground), $luminance($background)])
|
||||
->sortDesc()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return ($lighter + 0.05) / ($darker + 0.05);
|
||||
};
|
||||
|
||||
$themes = [
|
||||
[['#525252', '#666666', '#171717'], '#ffffff'],
|
||||
[['#a8a8b0', '#7a7a84', '#f2f2f2'], '#0c0c0d'],
|
||||
[['#e4dfea', '#d8d2df', '#ffffff', '#e4dfea', '#d8d2df'], '#58585a'],
|
||||
];
|
||||
|
||||
foreach ($themes as [$colors, $background]) {
|
||||
foreach ($colors as $color) {
|
||||
expect($styles)->toContain($color);
|
||||
expect($contrastRatio($color, $background))->toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('uses the shared status summary in the primary application server card', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/destination.blade.php'));
|
||||
$applicationSection = str($view)->before('@else')->value();
|
||||
|
||||
expect($applicationSection)
|
||||
->toContain('<x-status-summary :status="$resource->status" align="right" />')
|
||||
->not->toContain('<x-status :resource="$resource"');
|
||||
});
|
||||
|
||||
it('links each configured server card to its server page', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/destination.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain("route('server.show', ['server_uuid' => data_get(\$resource, 'destination.server.uuid')])")
|
||||
->toContain("route('server.show', ['server_uuid' => data_get(\$destination, 'server.uuid')])")
|
||||
->toContain('Open server');
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('compose actions are grouped with the application details header', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
$applicationDetails = str($view)
|
||||
->after('class="application-details-card">')
|
||||
->before('</x-application.settings-section>')
|
||||
->toString();
|
||||
|
||||
expect($applicationDetails)
|
||||
->toContain('<x-slot:actions>')
|
||||
->toContain('x-on:click="$wire.dispatch(\'loadCompose\', false)"')
|
||||
->toContain('Reload compose');
|
||||
});
|
||||
|
||||
test('compose file loading waits for the user to confirm the file location', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('x-on:click="$wire.dispatch(\'loadCompose\', false)"')
|
||||
->not->toContain('x-init="$wire.dispatch(\'loadCompose\', true)"');
|
||||
});
|
||||
|
||||
test('traffic analytics is only shown on the dedicated analytics page', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
|
||||
expect($view)->not->toContain('<livewire:project.application.traffic-overview');
|
||||
});
|
||||
|
||||
test('docker compose heading separates its title and action', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<div x-data="{ showRaw: true }" class="mt-5">')
|
||||
->toContain('<div class="mb-2 flex items-center justify-between gap-4">');
|
||||
});
|
||||
|
||||
test('unsaved changes ignore compose initialization state', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
$unsavedBar = str($view)
|
||||
->after('<x-unsaved-bar action="submit"')
|
||||
->before('/>')
|
||||
->toString();
|
||||
|
||||
expect($unsavedBar)
|
||||
->toContain('targets="')
|
||||
->toContain('name,description')
|
||||
->not->toContain('initLoadingCompose')
|
||||
->not->toContain('dockerComposeRaw')
|
||||
->not->toContain('dockerCompose,');
|
||||
});
|
||||
|
||||
test('onboarding uses the reusable advanced settings component', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
$onboarding = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('id="dockerComposeCustomBuildCommand"')
|
||||
->not->toContain('<x-forms.collapsible class="pt-4" content-class="grid gap-4">')
|
||||
->not->toContain('The following commands are for advanced use cases.')
|
||||
->and($onboarding)
|
||||
->toContain('<x-forms.collapsible title="Advanced Connection Settings"');
|
||||
});
|
||||
|
||||
test('deployments-only servers require a registry image because builds run elsewhere', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
$requiredImageCondition = str($view)
|
||||
->after("@if (\$application->build_pack === 'dockerimage')")
|
||||
->after('@else')
|
||||
->before('<x-forms.input id="dockerRegistryImageName" required label="Image"')
|
||||
->toString();
|
||||
|
||||
expect($requiredImageCondition)
|
||||
->toContain('$application->settings->is_build_server_enabled')
|
||||
->toContain('! $application->destination->server->canBuildApplications()');
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('shows internal Docker access details in application general settings', function () {
|
||||
$generalSettings = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
$generalComponent = file_get_contents(app_path('Livewire/Project/Application/General.php'));
|
||||
$internalAccessSettings = file_get_contents(resource_path('views/livewire/project/application/internal-access.blade.php'));
|
||||
$internalAccessComponent = file_get_contents(app_path('Livewire/Project/Application/InternalAccess.php'));
|
||||
$configurationSidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($generalSettings)
|
||||
->toContain('id="access-section" title="Access"')
|
||||
->toContain('<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Public access</h3>')
|
||||
->toContain('$primaryDomain = null;')
|
||||
->toContain('$additionalDomainCount = max(0, $domainCount - 1);')
|
||||
->toContain("aria-label=\"{{ \$domainCount > 0 ? 'Manage application domains' : 'Add an application domain' }}\"")
|
||||
->toContain('{{ $primaryDomain }}')
|
||||
->toContain("+{{ \$additionalDomainCount }} more {{ Str::plural('domain', \$additionalDomainCount) }}")
|
||||
->toContain('No public domain configured')
|
||||
->toContain('Make this application available from a URL')
|
||||
->toContain("{{ \$domainCount > 0 ? 'Manage domains' : 'Add domain' }}")
|
||||
->toContain('class="button relative z-10 ml-auto shrink-0"')
|
||||
->toContain('aria-label="{{ $domainCount > 0 ? \'Manage application domains\' : \'Add an application domain\' }}"')
|
||||
->toContain('<x-reicon name="arrow-right" class="size-4" />')
|
||||
->not->toContain('<x-reicon name="settings" class="size-4" />')
|
||||
->toContain('<livewire:project.application.internal-access')
|
||||
->not->toContain('wire:init="loadCurrentInternalHostname"')
|
||||
->and($internalAccessSettings)
|
||||
->toContain('id="internal-access-section"')
|
||||
->toContain('<h3 class="mb-4 text-sm font-semibold text-black dark:text-fg">Internal access</h3>')
|
||||
->not->toContain('<x-application.settings-section')
|
||||
->toContain('Internal hostname')
|
||||
->toContain('Docker network')
|
||||
->toContain('Exposed ports')
|
||||
->toContain('Network aliases')
|
||||
->toContain('wire:init="loadCurrentInternalHostname"')
|
||||
->toContain('$currentInternalHostname')
|
||||
->toContain('class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"')
|
||||
->not->toContain('Changes with each deployment')
|
||||
->toContain("window.scrollToSettingsSection?.('networking-section')")
|
||||
->and($generalComponent)
|
||||
->not->toContain('public function loadCurrentInternalHostname(): void')
|
||||
->and($internalAccessComponent)
|
||||
->toContain('public ?string $currentInternalHostname = null;')
|
||||
->toContain('public function loadCurrentInternalHostname(): void')
|
||||
->toContain('getCurrentApplicationContainerStatus(')
|
||||
->toContain("data_get(\$currentContainer, 'Names')")
|
||||
->and($configurationSidebar)
|
||||
->toContain("['id' => 'access-section', 'label' => 'Access']");
|
||||
});
|
||||
|
||||
it('does not show the internal access section for Docker Compose applications', function () {
|
||||
$generalSettings = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
$configurationSidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($generalSettings)
|
||||
->toContain("@if (\$buildPack !== 'dockercompose')")
|
||||
->and($configurationSidebar)
|
||||
->toContain("['id' => 'access-section', 'label' => 'Access']")
|
||||
->not->toContain("['id' => 'public-access-section', 'label' => 'Public access']")
|
||||
->not->toContain("['id' => 'internal-access-section', 'label' => 'Internal access']");
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Application configuration Rollback nav must use the reicon "time-back" glyph.
|
||||
*/
|
||||
test('application rollback menu uses the time-back icon', function () {
|
||||
$configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
|
||||
$reicon = file_get_contents(resource_path('views/components/reicon.blade.php'));
|
||||
|
||||
expect($configuration)
|
||||
->toMatch("/'Rollback'\\s*=>\\s*'time-back'/")
|
||||
->not->toMatch("/'Rollback'\\s*=>\\s*'logout'/");
|
||||
|
||||
expect($reicon)
|
||||
->toContain("'time-back' =>");
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('boarding utility actions are centered', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)->toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"');
|
||||
});
|
||||
|
||||
test('server validation opens in the centered process dialog', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-process-dialog closeWithX size="xl">')
|
||||
->toContain('@click="processDialogOpen = true"')
|
||||
->not->toContain('<x-slide-over closeWithX fullScreen>');
|
||||
});
|
||||
|
||||
test('DigitalOcean is available as an onboarding server provider', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-modal-input title="Connect a DigitalOcean Server" isFullWidth>')
|
||||
->toContain('<x-digital-ocean-icon class="size-10 shrink-0" />')
|
||||
->toContain('Deploy servers directly from your DigitalOcean account.')
|
||||
->toContain('<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />');
|
||||
});
|
||||
|
||||
test('server type details are shown on the relevant cards instead of a technical details panel', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('aria-label="About this machine"')
|
||||
->toContain('aria-label="About remote servers"')
|
||||
->toContain('Not recommended for production workloads due to resource contention.')
|
||||
->toContain('Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure.')
|
||||
->not->toContain('<x-highlighted text="Servers" />')
|
||||
->not->toContain('<x-highlighted text="Localhost:" />')
|
||||
->not->toContain('<x-highlighted text="Remote Server:" />');
|
||||
});
|
||||
|
||||
test('server type cards use the standard card hover treatment', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
$hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md';
|
||||
preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches);
|
||||
|
||||
expect($matches[0])->toHaveCount(5)
|
||||
->and(substr_count($view, 'group relative cursor-pointer'))->toBe(5)
|
||||
->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]')
|
||||
->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]');
|
||||
});
|
||||
|
||||
test('existing SSH key selection does not show a redundant value tooltip', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)->toContain('label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false"');
|
||||
});
|
||||
|
||||
test('server connection step does not render a technical details panel', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('<x-highlighted text="Connection Requirements:" />')
|
||||
->not->toContain('<x-highlighted text="Hostname Resolution:" />')
|
||||
->not->toContain('<x-highlighted text="User Permissions:" />');
|
||||
});
|
||||
|
||||
test('project step does not render a technical details panel', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('<x-highlighted text="Project Organization:" />')
|
||||
->not->toContain('<x-highlighted text="Environments:" />')
|
||||
->not->toContain('<x-highlighted text="Team Access:" />');
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('breadcrumb switchers indicate the current item with background only', function () {
|
||||
$views = collect([
|
||||
resource_path('views/components/top-breadcrumb.blade.php'),
|
||||
resource_path('views/components/breadcrumb-switcher.blade.php'),
|
||||
resource_path('views/livewire/switch-team.blade.php'),
|
||||
])->map(fn (string $path): string => file_get_contents($path));
|
||||
|
||||
expect($views->implode("\n"))
|
||||
->not->toContain('name="check-circle"')
|
||||
->and($views->every(fn (string $view): bool => str_contains($view, 'bg-neutral-100')))
|
||||
->toBeTrue();
|
||||
});
|
||||
|
||||
test('teleported resource breadcrumbs use the same spacing as application breadcrumbs', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4"')
|
||||
->not->toContain('class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4"');
|
||||
});
|
||||
|
||||
test('top breadcrumbs shrink and clip instead of overlapping on narrower screens', function () {
|
||||
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($breadcrumb)
|
||||
->toContain('class="flex w-full min-w-0 items-center gap-0.5 text-[13px]"')
|
||||
->not->toContain('overflow-hidden text-[13px]')
|
||||
->toContain('class="min-w-0 shrink" x-data="{ collapsed: false }"')
|
||||
->not->toContain('class="shrink-0" x-data="{ collapsed: false }"')
|
||||
->and($layout)
|
||||
->toContain('class="relative flex min-w-0 flex-1 items-center"')
|
||||
->not->toContain('flex min-w-0 flex-1 items-center overflow-hidden')
|
||||
->not->toContain('<div class="flex-1"></div>');
|
||||
});
|
||||
|
||||
test('resource headings do not duplicate database and service breadcrumbs', function () {
|
||||
$headings = collect([
|
||||
resource_path('views/livewire/project/database/heading.blade.php'),
|
||||
resource_path('views/livewire/project/service/heading.blade.php'),
|
||||
])->map(fn (string $path): string => file_get_contents($path));
|
||||
|
||||
expect($headings->implode("\n"))->not->toContain("@teleport('#server-topbar-context')");
|
||||
});
|
||||
|
||||
test('top breadcrumb lets users switch between resources in the current environment', function () {
|
||||
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
|
||||
expect($breadcrumb)
|
||||
->toContain('$currentEnvironment->applications')
|
||||
->toContain('$currentEnvironment->databases()')
|
||||
->toContain('$currentEnvironment->services')
|
||||
->toContain('<x-breadcrumb-switcher title="Resources"')
|
||||
->toContain("'application' => route('project.application.configuration'")
|
||||
->toContain("'database' => route('project.database.configuration'")
|
||||
->toContain("'service' => route('project.service.configuration'");
|
||||
});
|
||||
|
||||
test('breadcrumb switchers let users search their items', function () {
|
||||
$switcher = file_get_contents(resource_path('views/components/breadcrumb-switcher.blade.php'));
|
||||
|
||||
expect($switcher)
|
||||
->toContain('x-model.debounce.150ms="search"')
|
||||
->toContain('type="search"')
|
||||
->toContain('placeholder="Search {{ strtolower($title) }}"')
|
||||
->toContain('class="searchable-listbox-search"')
|
||||
->toContain('class="searchable-listbox-search-input"')
|
||||
->toContain('<x-reicon name="search"')
|
||||
->toContain('left-4 size-3')
|
||||
->toContain('.includes(search.toLowerCase())');
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Terminal-related UI must use the redesign "browser-terminal" reicon, not the
|
||||
* older "terminal" (four-way arrows) glyph.
|
||||
*/
|
||||
test('terminal and console views use the browser-terminal icon', function () {
|
||||
$files = [
|
||||
resource_path('views/components/navbar.blade.php'),
|
||||
resource_path('views/components/backup-sidebar.blade.php'),
|
||||
resource_path('views/components/server/sidebar-security.blade.php'),
|
||||
resource_path('views/livewire/terminal/index.blade.php'),
|
||||
resource_path('views/livewire/project/shared/terminal.blade.php'),
|
||||
resource_path('views/livewire/project/shared/execute-container-command.blade.php'),
|
||||
resource_path('views/livewire/project/shared/scheduled-task/all.blade.php'),
|
||||
resource_path('views/livewire/project/shared/scheduled-task/executions.blade.php'),
|
||||
resource_path('views/livewire/project/database/backup-executions.blade.php'),
|
||||
resource_path('views/livewire/server/security/terminal-access.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($files as $path) {
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)
|
||||
->not->toMatch('/name=["\']terminal["\']/')
|
||||
->not->toMatch('/icon-name=["\']terminal["\']/')
|
||||
->not->toMatch("/'icon'\\s*=>\\s*'terminal'/");
|
||||
}
|
||||
});
|
||||
|
||||
test('reicon pack includes browser-terminal glyph', function () {
|
||||
$path = resource_path('views/components/reicon.blade.php');
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)->toContain("'browser-terminal' =>");
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('standard buttons use raised hover and pressed depth states', function () {
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($styles)
|
||||
->toContain('--button-depth-color: rgb(0 0 0 / 0.22);')
|
||||
->toContain('--button-depth: 0 2px 0 var(--button-depth-color);')
|
||||
->toContain('--button-depth-hover: 0 3px 0 var(--button-depth-color);')
|
||||
->toContain('--button-depth-color: var(--color-coollabs-300);')
|
||||
->toContain('.dark .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled)')
|
||||
->not->toContain('.dark .button:not(.button-highlighted):not([isHighlighted]):not(:disabled)')
|
||||
->toContain('--button-depth-color: rgb(255 255 255 / 0.08);')
|
||||
->not->toContain('.dark .button.button-highlighted:not(:disabled)')
|
||||
->toContain('.button.button-error:not(:disabled)')
|
||||
->toContain('--button-depth-color: var(--color-red-300);')
|
||||
->toContain('.dark .button.button-error:not(:disabled)')
|
||||
->toContain('--button-depth-color: var(--color-red-800);')
|
||||
->toContain('.button-error:not(:disabled)')
|
||||
->toContain('.button.button-error {')
|
||||
->toContain('.button.button-error:disabled {')
|
||||
->toContain('hover:bg-red-100 hover:text-red-900 dark:hover:bg-red-700 dark:hover:text-white')
|
||||
->not->toContain('hover:bg-red-300 hover:text-white dark:hover:bg-red-800')
|
||||
->not->toContain('button[isError]:not(:disabled)')
|
||||
->toContain('.button:not(:disabled):hover')
|
||||
->toContain('transform: translateY(-1px);')
|
||||
->toContain('transition-duration: 120ms, 120ms, 120ms, 80ms, 80ms;')
|
||||
->toContain('.button:not(:disabled):active')
|
||||
->toContain('transform: translateY(2px);')
|
||||
->not->toContain('transform: translateY(1px);')
|
||||
->not->toContain('transform: translateY(3px);')
|
||||
->toContain('box-shadow: none;');
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('uses the compact height for all standard buttons', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
$applicationStyles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($utilities)->toContain('px-2.5 h-8 min-h-8')
|
||||
->and($applicationStyles)
|
||||
->not->toContain('.application-settings-workspace .button')
|
||||
->not->toContain('.application-settings-form .button');
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('standard buttons capitalize each word in their labels', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
preg_match('/@utility button \{(?<styles>[\s\S]*?)\n\}/', $utilities, $matches);
|
||||
|
||||
expect($matches['styles'] ?? '')
|
||||
->toContain('capitalize')
|
||||
->not->toContain('normal-case');
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* The custom theme derives its whole palette from one user-picked color.
|
||||
* These tests lock in two guarantees:
|
||||
* 1. The pipeline is authored entirely in OKLCH (no hex color literals).
|
||||
* 2. Foreground colors (accent text, placeholders) are clamped so they stay
|
||||
* readable on the dark surfaces regardless of the picked hue.
|
||||
*/
|
||||
function customThemeBlock(): string
|
||||
{
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
return str($css)
|
||||
->after('html[data-theme="custom"] {')
|
||||
->before('}')
|
||||
->toString();
|
||||
}
|
||||
|
||||
it('derives the custom theme entirely in oklch with no hex color literals', function () {
|
||||
expect(customThemeBlock())
|
||||
->not->toMatch('/#[0-9a-fA-F]{3,8}\b/')
|
||||
->toContain('--theme-base-color:')
|
||||
->toContain('oklch(');
|
||||
});
|
||||
|
||||
it('adds clamped, readable foreground tokens for the custom theme', function () {
|
||||
$block = customThemeBlock();
|
||||
|
||||
expect($block)
|
||||
// Accent text/icons on dark surfaces: lightness forced into a readable band.
|
||||
->toContain('--theme-fg-on-surface:')
|
||||
->toMatch('/--theme-fg-on-surface:\s*oklch\(from var\(--theme-base-color\)[^;]*clamp\(/')
|
||||
// Placeholder: light, near-neutral, guaranteed readable.
|
||||
->toContain('--theme-placeholder-color:')
|
||||
->toMatch('/--theme-placeholder-color:\s*oklch\(from var\(--theme-base-color\)/');
|
||||
});
|
||||
|
||||
it('routes custom-theme accent text utilities to the readable on-surface token', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toMatch('/\[class~="text-warning"\][^{]*\{\s*color:\s*var\(--theme-fg-on-surface\)/')
|
||||
->toContain('[class~="text-coollabs"]');
|
||||
});
|
||||
|
||||
it('uses the clamped placeholder token for custom-theme inputs', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toMatch('/html\[data-theme="custom"\] input::placeholder[^}]*var\(--theme-placeholder-color\)/');
|
||||
});
|
||||
|
||||
it('adds a light custom variant that tints surfaces toward white with dark foreground', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toContain('html[data-theme="custom"]:not(.dark)')
|
||||
// Surfaces hold a fixed high lightness with injected base-hue chroma, so the
|
||||
// tint is visible (not a washed-out mix into white).
|
||||
->toMatch('/html\[data-theme="custom"\]:not\(\.dark\)[^}]*--color-app:\s*oklch\(from var\(--theme-base-color\) 9[0-9](\.[0-9]+)?% min\(c,/s')
|
||||
// Accent text/icons clamp to a dark, readable band on light surfaces.
|
||||
->toMatch('/html\[data-theme="custom"\]:not\(\.dark\)[^}]*--theme-fg-on-surface:\s*oklch\(from var\(--theme-base-color\) clamp\(0\.3/s');
|
||||
});
|
||||
|
||||
it('lets the theme scripts toggle a custom light or dark mode via customMode', function () {
|
||||
$base = file_get_contents(resource_path('views/layouts/base.blade.php'));
|
||||
$component = file_get_contents(resource_path('views/components/theme-controls.blade.php'));
|
||||
$picker = file_get_contents(resource_path('views/components/theme-controls/picker.blade.php'));
|
||||
$appearance = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
|
||||
$accountMenu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
|
||||
|
||||
expect($base)
|
||||
// One shared Alpine factory holds the theme logic.
|
||||
->toContain('window.themeControls')
|
||||
->toContain('localStorage.customMode')
|
||||
->toContain('setCustomMode')
|
||||
// The picker no longer force-adds dark; it follows customMode.
|
||||
->not->toContain("document.documentElement.classList.add('dark')")
|
||||
// Light/dark lives inside the custom color picker popover.
|
||||
->and($picker)
|
||||
->toContain('customMode')
|
||||
->toContain('setCustomMode')
|
||||
->toContain('type="color"')
|
||||
->and($component)
|
||||
// Both variants open the shared picker popover for Custom.
|
||||
->toContain('<x-theme-controls.picker')
|
||||
->toContain('chooseCustom')
|
||||
// Both the page and the dropdown reuse the one component.
|
||||
->and($appearance)
|
||||
->toContain('<x-theme-controls variant="full"')
|
||||
->and($accountMenu)
|
||||
->toContain('<x-theme-controls variant="menu"');
|
||||
});
|
||||
|
||||
it('computes the theme base color and accent foreground in oklch, not hex', function () {
|
||||
$base = file_get_contents(resource_path('views/layouts/base.blade.php'));
|
||||
|
||||
$accentFn = str($base)
|
||||
->after('window.themeAccentForeground')
|
||||
->before('window.applyStoredTheme')
|
||||
->toString();
|
||||
|
||||
expect($base)
|
||||
->toContain('window.hexToOklch')
|
||||
->and($accentFn)
|
||||
->toContain('oklch(0% 0 0)')
|
||||
->toContain('oklch(100% 0 0)')
|
||||
->not->toContain('#000000')
|
||||
->not->toContain('#ffffff');
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('uses one shared visual treatment for all danger zone actions', function () {
|
||||
$component = file_get_contents(resource_path('views/components/danger-zone.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain("@props(['title'])")
|
||||
->toContain('sm:flex-row sm:items-start sm:justify-between')
|
||||
->toContain('rounded-lg border border-red-200 bg-red-50')
|
||||
->toContain('dark:border-red-500/25 dark:bg-red-500/[0.08]')
|
||||
->toContain('Permanent')
|
||||
->toContain('@isset($action)');
|
||||
|
||||
foreach ([
|
||||
'livewire/destination/show.blade.php',
|
||||
'livewire/project/database/backup-edit/danger.blade.php',
|
||||
'livewire/project/shared/danger.blade.php',
|
||||
'livewire/project/shared/storages/volume-backups/danger.blade.php',
|
||||
'livewire/server/delete.blade.php',
|
||||
'livewire/source/github/change.blade.php',
|
||||
'livewire/storage/show.blade.php',
|
||||
'livewire/team/danger-zone.blade.php',
|
||||
] as $view) {
|
||||
expect(file_get_contents(resource_path('views/'.$view)))
|
||||
->toContain('<x-danger-zone')
|
||||
->not->toContain('rounded-lg border border-red-300 bg-red-50');
|
||||
}
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('settings header navigation is replaced by the settings sidebar', function () {
|
||||
$path = resource_path('views/components/dashboard/navbar.blade.php');
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)
|
||||
->toContain("['label' => 'General', 'route' => 'settings.index', 'active' => request()->routeIs('settings.*')]")
|
||||
->not->toContain("['label' => 'Email', 'route' => 'settings.email'");
|
||||
});
|
||||
|
||||
test('dashboard navbar keeps the original side-by-side tab and actions layout', function () {
|
||||
$path = resource_path('views/components/dashboard/navbar.blade.php');
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)
|
||||
->toContain('flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between')
|
||||
->toContain('flex min-w-0 w-full items-center gap-0.5 overflow-x-auto');
|
||||
});
|
||||
|
||||
test('dashboard view all links use the shared button style', function () {
|
||||
$contents = file_get_contents(resource_path('views/components/section-heading.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain('class="button group"')
|
||||
->not->toContain('class="group inline-flex h-7');
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('uses a database list editor instead of a comma-separated text field', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<div class="grid w-full gap-4">')
|
||||
->toContain("['value' => true, 'label' => 'All databases']")
|
||||
->toContain("['value' => false, 'label' => 'Specific databases']")
|
||||
->toContain("value: @entangle('databasesToBackup').live")
|
||||
->toContain('class="chip-input"')
|
||||
->toContain('class="chip font-mono"')
|
||||
->toContain('class="chip-remove"')
|
||||
->toContain('addDatabase()')
|
||||
->toContain('removeDatabase(index)')
|
||||
->toContain('Type a database and press Enter');
|
||||
|
||||
expect(substr_count($view, '<x-forms.input label="Databases to back up"'))->toBe(1);
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('matches the storage backup overview layout', function () {
|
||||
$index = file_get_contents(resource_path('views/livewire/project/database/backup/index.blade.php'));
|
||||
$schedules = file_get_contents(resource_path('views/livewire/project/database/scheduled-backups.blade.php'));
|
||||
$component = file_get_contents(app_path('Livewire/Project/Database/Backup/Index.php'));
|
||||
|
||||
expect($index)
|
||||
->toContain('title="Database backups"')
|
||||
->toContain('Schedules')
|
||||
->toContain('Enabled')
|
||||
->toContain('Total executions')
|
||||
->toContain('application-settings-form flex min-w-0 flex-col gap-6')
|
||||
->and($schedules)
|
||||
->toContain('placeholder="Search backups"')
|
||||
->toContain('data-table overflow-hidden rounded-xl border')
|
||||
->toContain('<span class="block truncate text-[12px] font-semibold')
|
||||
->toContain("route('project.database.backup.execution'")
|
||||
->toContain("route('project.service.database.backup.show'")
|
||||
->toContain("route('project.database.backup.executions'")
|
||||
->toContain("route('project.service.database.backup.executions'")
|
||||
->toContain('<a wire:navigate href="{{ $backupExecutionsRoute }}"')
|
||||
->toContain('<a class="button" wire:navigate href="{{ $backupRoute }}">Manage</a>')
|
||||
->and($component)->toContain("withCount('executions')");
|
||||
});
|
||||
|
||||
it('returns from database backup settings to the database', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/backup-sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain("'back' => 'project.database.configuration'")
|
||||
->toContain("'Back to database'")
|
||||
->toContain("except('backup_uuid')");
|
||||
});
|
||||
|
||||
it('shows the backup path directly on every execution', function () {
|
||||
$executions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($executions)
|
||||
->toContain('Backup path:')
|
||||
->toContain("data_get(\$execution, 'filename', 'N/A')")
|
||||
->toContain('<span>Backup path</span>')
|
||||
->toContain('class="select-all truncate font-mono text-[11px]')
|
||||
->not->toContain('backup-executions-table-grid border-t');
|
||||
});
|
||||
|
||||
it('keeps backup execution actions in the normal table flow', function () {
|
||||
$executions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($executions)
|
||||
->not->toContain('sticky right-0');
|
||||
});
|
||||
|
||||
it('spaces instance backup settings sections', function () {
|
||||
$edit = file_get_contents(resource_path('views/livewire/project/database/backup-edit.blade.php'));
|
||||
|
||||
expect($edit)
|
||||
->toStartWith('<div class="flex flex-col gap-6">');
|
||||
});
|
||||
|
||||
it('shows database backup section descriptions from the shared title helper', function () {
|
||||
$general = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
|
||||
$retention = file_get_contents(resource_path('views/livewire/project/database/backup-edit/retention.blade.php'));
|
||||
|
||||
expect($general)
|
||||
->toContain('<x-application.settings-section title="Backup schedule"')
|
||||
->not->toContain('<h2>Backup schedule</h2>')
|
||||
->and($retention)
|
||||
->toContain('<x-application.settings-section title="Retention"')
|
||||
->not->toContain('<h2>Retention</h2>');
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('shows a loading indicator while database public access is changing', function () {
|
||||
$databaseTypes = [
|
||||
'clickhouse',
|
||||
'dragonfly',
|
||||
'keydb',
|
||||
'mariadb',
|
||||
'mongodb',
|
||||
'mysql',
|
||||
'postgresql',
|
||||
'redis',
|
||||
];
|
||||
|
||||
foreach ($databaseTypes as $databaseType) {
|
||||
$generalSettings = file_get_contents(resource_path("views/livewire/project/database/{$databaseType}/general.blade.php"));
|
||||
|
||||
expect($generalSettings)
|
||||
->toContain('<x-table.loading target="instantSave" text="Updating public access..." />')
|
||||
->toContain("'label' => blank(\$publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy'")
|
||||
->toContain("'disabled' => blank(\$publicPort)")
|
||||
->toContain('wire:key="public-access-{{ $publicPort ?: \'unset\' }}"')
|
||||
->not->toContain('x-data="{ publicPort:')
|
||||
->not->toContain('x-effect="options[1].disabled')
|
||||
->not->toContain('id="publicPort" x-on:input=')
|
||||
->not->toContain('id="publicPort" live');
|
||||
}
|
||||
});
|
||||
|
||||
it('passes the selected PostgreSQL public access state to the save action', function () {
|
||||
$generalSettings = file_get_contents(resource_path('views/livewire/project/database/postgresql/general.blade.php'));
|
||||
|
||||
expect($generalSettings)
|
||||
->toContain('onChange="instantSave" :onChangeArgs="[]"');
|
||||
|
||||
$component = file_get_contents(app_path('Livewire/Project/Database/Postgresql/General.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('public function instantSave(?bool $isPublic = null)')
|
||||
->toContain('$this->isPublic = $isPublic;');
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('database restore output opens in the centered process dialog', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-process-dialog @databaserestore.window="processDialogOpen = true" closeWithX size="xl">')
|
||||
->toContain('<livewire:activity-monitor wire:key="database-restore-{{ $resourceUuid }}" header="Logs" fullHeight />')
|
||||
->not->toContain('<x-slide-over @databaserestore.window="slideOverOpen = true"');
|
||||
});
|
||||
|
||||
test('postgresql dump all restore warns that administrator passwords are overwritten', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain("@if (\$resourceDbType === 'standalone-postgresql' && \$dumpAll)")
|
||||
->toContain('Full restore overwrites administrator passwords')
|
||||
->toContain('The backup replaces PostgreSQL administrator role passwords, including the destination administrator password.')
|
||||
->toContain("If the administrator password changes, update it in Coolify's database configuration after the restore.");
|
||||
});
|
||||
|
||||
test('postgresql single database restore offers explicit object replacement', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('Replace objects that already exist')
|
||||
->toContain('wire:model="restoreCommandText"')
|
||||
->toContain('readonly label="Import command"');
|
||||
});
|
||||
|
||||
test('restore confirmation dialogs warn that existing objects can block the import', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('submitAction="runImport"')
|
||||
->toContain('submitAction="restoreFromS3"')
|
||||
->toContain('Existing objects can cause the import to fail unless replacement is enabled.')
|
||||
->not->toContain('All existing data will be replaced.');
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('new users default to dark mode', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('<html data-theme="dark"')
|
||||
->toContain("const t = localStorage.theme || 'dark';")
|
||||
->toContain("localStorage.theme = 'dark';")
|
||||
->toContain('<meta name="theme-color" content="#101010" id="theme-color-meta" />');
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('active deployment log controls use a coollabs background and white icon', function () {
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($styles)
|
||||
->toMatch('/\.logs-viewer-btn-active\s*\{[^}]*background:\s*var\(--color-coollabs\);[^}]*color:\s*#fff;/s')
|
||||
->toMatch('/\.dark \.logs-viewer-btn-active\s*\{[^}]*color:\s*#fff;/s')
|
||||
->not->toMatch('/\.logs-viewer-btn-active\s*\{[^}]*var\(--color-warning\)/s');
|
||||
});
|
||||
|
||||
test('deployment logs use light surfaces in light mode and dark surfaces in dark mode', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($view)
|
||||
->toContain('bg-white text-neutral-800')
|
||||
->toContain('dark:bg-log dark:text-neutral-100')
|
||||
->toContain('border-neutral-200 shadow-sm dark:border-coolgray-200')
|
||||
->toContain('border-neutral-200! bg-white!')
|
||||
->toContain('dark:border-white/[0.08]! dark:bg-white/[0.05]!')
|
||||
->and($styles)
|
||||
->toMatch('/\.logs-viewer\s*\{[^}]*background:\s*#fff;[^}]*color:\s*#262626;/s')
|
||||
->toMatch('/\.dark \.logs-viewer\s*\{[^}]*background:\s*var\(--color-log\);/s');
|
||||
});
|
||||
|
||||
test('log search hides lines that do not match', function () {
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($styles)
|
||||
->toMatch('/\.logs-viewer-line\.hidden\s*\{[^}]*display:\s*none;/s');
|
||||
});
|
||||
|
||||
test('turning off follow logs stays off at the bottom of the log viewer', function () {
|
||||
$views = [
|
||||
resource_path('views/livewire/project/application/deployment/show.blade.php'),
|
||||
resource_path('views/livewire/project/shared/get-logs.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))
|
||||
->toContain('followManuallyDisabled: false')
|
||||
->toContain('!this.followManuallyDisabled && distanceFromBottom <= 10')
|
||||
->toContain('this.followManuallyDisabled = true')
|
||||
->toContain('this.followManuallyDisabled = false');
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime log follow loop is cleaned up when navigating away', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('scrollTimeout: null')
|
||||
->toContain("this.\$root.querySelector('#logsContainer')")
|
||||
->toContain('destroy() {')
|
||||
->toContain('this.destroyed = true')
|
||||
->toContain('this.cancelScrollLoop()');
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('positions the deployments indicator from the sidebar collapsed state', function () {
|
||||
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
|
||||
$layoutView = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($indicatorView)
|
||||
->toContain('transition-[left] duration-200')
|
||||
->toContain(":class=\"collapsed ? 'lg:left-16' : 'lg:left-56'\"")
|
||||
->not->toContain('fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4');
|
||||
|
||||
expect($layoutView)
|
||||
->toContain('<div x-data="{')
|
||||
->toContain('<livewire:deployments-indicator />');
|
||||
|
||||
expect(strpos($layoutView, '<div x-data="{'))
|
||||
->toBeLessThan(strpos($layoutView, '<livewire:deployments-indicator />'));
|
||||
});
|
||||
|
||||
it('uses the redesigned elevated surface and status badge patterns', function () {
|
||||
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
|
||||
|
||||
expect($indicatorView)
|
||||
->toContain('var(--coollabs-elevated)')
|
||||
->toContain('var(--coollabs-line)')
|
||||
->toContain('var(--shadow-modal)')
|
||||
->toContain('var(--coollabs-hairline)')
|
||||
->toContain('<x-status-badge')
|
||||
->toContain('name="chevron-down"')
|
||||
->toContain("'In progress'")
|
||||
->toContain("'Queued'")
|
||||
->toContain('dark:text-fg')
|
||||
->toContain('dark:border-coolgray-300')
|
||||
->toContain('$this->shouldShow')
|
||||
->toContain('updateShouldShowFromPath')
|
||||
->toContain('livewire:navigated')
|
||||
->not->toContain('dark:bg-coolgray-100')
|
||||
->not->toContain('dark:bg-coolgray-200')
|
||||
->not->toContain('text-gray-800')
|
||||
->not->toContain("str_replace('_', ' ', \$deployment->status)");
|
||||
});
|
||||
|
||||
it('uses solid surfaces in every deployments indicator state', function () {
|
||||
$indicatorView = file_get_contents(resource_path('views/livewire/deployments-indicator.blade.php'));
|
||||
|
||||
expect($indicatorView)
|
||||
->not->toContain('reduceOpacity')
|
||||
->not->toContain('transition-opacity')
|
||||
->not->toContain('x-transition:enter-start="opacity-0')
|
||||
->not->toContain('x-transition:leave-end="opacity-0')
|
||||
->not->toContain('dark:bg-white/[')
|
||||
->not->toContain('dark:hover:bg-white/[')
|
||||
->not->toContain('dark:border-white/[');
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('returns after creating a destination and shows submit loading state', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Destination/New/Docker.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/destination/new/docker.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain("return redirectRoute(\$this, 'destination.show', [\$docker->uuid]);");
|
||||
expect($view)
|
||||
->toContain('wire:submit="submit"')
|
||||
->toContain('wire:target="submit"');
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('uses sidebar navigation and breadcrumbs for destination details', function () {
|
||||
$show = file_get_contents(resource_path('views/livewire/destination/show.blade.php'));
|
||||
$resources = file_get_contents(resource_path('views/livewire/destination/resources.blade.php'));
|
||||
$sidebar = file_get_contents(resource_path('views/livewire/destination/sidebar.blade.php'));
|
||||
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
||||
$breadcrumbs = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
|
||||
expect($show)
|
||||
->toContain(':mobileTitleOnly="true"')
|
||||
->toContain("@include('livewire.destination.sidebar'");
|
||||
expect($resources)
|
||||
->toContain(':mobileTitleOnly="true"')
|
||||
->toContain("@include('livewire.destination.sidebar'");
|
||||
expect($sidebar)
|
||||
->toContain("'label' => 'General'")
|
||||
->toContain("'label' => 'Resources'")
|
||||
->toContain("'label' => 'Danger Zone'")
|
||||
->toContain('application-settings-navigation min-w-0 xl:self-start');
|
||||
expect($navbar)
|
||||
->toContain("request()->routeIs('destination.show', 'destination.resources', 'destination.danger')")
|
||||
->not->toContain("['label' => 'Resources', 'route' => 'destination.resources'");
|
||||
expect($breadcrumbs)
|
||||
->toContain('x-breadcrumb-switcher')
|
||||
->toContain('$currentDestination->name')
|
||||
->toContain("route('destination.show'")
|
||||
->toContain("? 'Docker' : 'Deprecated'");
|
||||
expect($breadcrumbs)
|
||||
->toContain('Pages')
|
||||
->toContain('Projects')
|
||||
->toContain('Environments')
|
||||
->not->toContain('M5 12l5 5 9-11');
|
||||
expect($show)
|
||||
->toContain('destination-danger-section')
|
||||
->toContain('title="Danger zone"')
|
||||
->not->toContain("actions=['This will delete the selected destination/network.']");
|
||||
expect(file_get_contents(base_path('routes/web.php')))
|
||||
->toContain("->name('destination.danger')");
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('docker cleanup execution output uses light and dark theme surfaces', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('bg-white text-neutral-700')
|
||||
->toContain('dark:bg-neutral-950 dark:text-neutral-300');
|
||||
});
|
||||
|
||||
test('docker cleanup executions show a loading state while opening an execution', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:loading.attr="disabled"')
|
||||
->toContain('wire:target="selectExecution({{ data_get($execution, \'id\') }})"')
|
||||
->toContain('wire:loading.remove')
|
||||
->toContain('<x-loading wire:loading');
|
||||
});
|
||||
|
||||
test('docker cleanup explains application image retention options', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('Choose Keep retained images to follow each application’s Images to keep setting under Rollback.')
|
||||
->toContain('Delete all old images ignores that setting and keeps only the running image.');
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('keeps spacing around the Docker Compose editor', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/new/docker-compose.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="application-settings-section-body"')
|
||||
->not->toContain('class="application-settings-section-body p-0!"');
|
||||
});
|
||||
|
||||
it('lets the Docker Compose editor use the available width', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/new/docker-compose.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="mt-8 w-full lg:mt-3"')
|
||||
->not->toContain('max-w-[1180px]');
|
||||
});
|
||||
|
||||
it('shows a loading indicator while creating the service', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/new/docker-compose.blade.php'));
|
||||
|
||||
expect($view)->toContain('<x-forms.button type="submit" wire:target="submit" isHighlighted>');
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('shows that image tag and digest are mutually exclusive', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/new/docker-image.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('aria-label="Tag and SHA256 digest are mutually exclusive"')
|
||||
->toContain('>OR</span>');
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('uses the Coolify base background for all empty states', function () {
|
||||
$html = Blade::render('<x-empty title="Nothing found" />');
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($html)
|
||||
->toContain('empty-state')
|
||||
->not->toContain('bg-neutral-50')
|
||||
->not->toContain('dark:bg-white/[0.02]')
|
||||
->and($css)
|
||||
->toContain('.application-settings-section-body:has(> .empty-state:only-child)')
|
||||
->toContain('.application-settings-section-body.is-flush:has(> .empty-state:only-child)')
|
||||
->toContain('padding: 1rem;')
|
||||
->toContain('.empty-state')
|
||||
->toContain('background: var(--coollabs-base);')
|
||||
->toContain('.loading-state-card')
|
||||
->toContain('border: 1px dashed var(--coollabs-line);');
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('uses highlighted styling for enable toggle actions', function () {
|
||||
$views = [
|
||||
resource_path('views/components/notification/channel-actions.blade.php'),
|
||||
resource_path('views/livewire/settings-oauth.blade.php'),
|
||||
resource_path('views/livewire/project/shared/scheduled-task/show.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))->toContain('isHighlighted');
|
||||
}
|
||||
|
||||
$terminalAccess = file_get_contents(resource_path('views/livewire/server/security/terminal-access.blade.php'));
|
||||
|
||||
expect($terminalAccess)->toContain(':isHighlightedButton="!$isTerminalEnabled"');
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('environment delete button label is simply Delete', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/delete-environment.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('buttonTitle="Delete"')
|
||||
->not->toContain('buttonTitle="Delete Environment"');
|
||||
});
|
||||
|
||||
test('environment delete section stacks on small screens', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/environment-edit.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('flex flex-col gap-4 px-5 py-4 sm:flex-row sm:items-start sm:justify-between')
|
||||
->toContain('livewire:project.delete-environment');
|
||||
});
|
||||
|
||||
test('app-tab utility includes icon text spacing', function () {
|
||||
$css = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
expect($css)->toMatch('/@utility app-tab \{[^}]*gap-1/s');
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('uses the simplified database backup execution heading and spacing', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">')
|
||||
->toContain('<h2 class="py-0">Executions</h2>')
|
||||
->not->toContain('Executions <span')
|
||||
->toContain('class="flex flex-col gap-4 pt-2"');
|
||||
});
|
||||
|
||||
it('renders scheduled task executions as selectable status cards with expandable logs', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/scheduled-task/executions.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<div class="flex flex-col gap-2" wire:poll.5000ms="refreshExecutions"')
|
||||
->toContain('<a wire:click="selectTask({{ data_get($execution, \'id\') }})"')
|
||||
->toContain('border-l-2 transition-colors p-4 cursor-pointer')
|
||||
->toContain("'success' => 'Success'")
|
||||
->toContain("'running' => 'In Progress'")
|
||||
->toContain("'failed' => 'Failed'")
|
||||
->toContain("data_get(\$execution, 'id') == \$selectedKey")
|
||||
->toContain('max-h-[600px] overflow-y-auto')
|
||||
->toContain('No executions found.');
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('default form buttons and inputs share the same control height', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
expect($utilities)
|
||||
->toContain('@utility input-select {')
|
||||
->toContain('@utility button {');
|
||||
|
||||
preg_match('/@utility input-select \{[^}]*\}/s', $utilities, $inputSelect);
|
||||
preg_match('/@utility button \{[^}]*\}/s', $utilities, $button);
|
||||
|
||||
expect($inputSelect[0] ?? '')
|
||||
->toContain('h-8')
|
||||
->not->toContain('h-9')
|
||||
->and($button[0] ?? '')->toContain('h-8')
|
||||
->and($button[0] ?? '')->toContain('min-h-8')
|
||||
->and($button[0] ?? '')->toContain('whitespace-nowrap')
|
||||
->and($button[0] ?? '')->toContain('shrink-0')
|
||||
->and($button[0] ?? '')->not->toContain('h-9');
|
||||
});
|
||||
|
||||
test('settings form surfaces keep input and button heights equal', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
preg_match(
|
||||
'/\.application-settings-workspace \.input,[\s\S]*?\.application-settings-form \.select \{[\s\S]*?\}/',
|
||||
$css,
|
||||
$inputs
|
||||
);
|
||||
preg_match(
|
||||
'/\.application-settings-workspace \.button,[\s\S]*?\.application-settings-form \.button \{[\s\S]*?\}/',
|
||||
$css,
|
||||
$buttons
|
||||
);
|
||||
|
||||
expect($inputs[0] ?? '')
|
||||
->toContain('height: 2rem;')
|
||||
->and($buttons[0] ?? '')->toContain('height: 2rem;')
|
||||
->and($buttons[0] ?? '')->toContain('min-height: 2rem;')
|
||||
->and($buttons[0] ?? '')->toContain('white-space: nowrap;');
|
||||
});
|
||||
|
||||
test('directory storage actions wrap on narrow viewports instead of stacking uneven heights', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/file-storage.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('flex flex-wrap items-center gap-2')
|
||||
->toContain('Convert to file')
|
||||
->toContain('Configure Backup');
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('the application shell and resource workspaces use the full available width', function () {
|
||||
$appLayout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
$applicationConfiguration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
|
||||
$serverConfiguration = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
|
||||
|
||||
expect($appLayout)
|
||||
->toContain("pageWidth === 'centered' ? 'mx-auto max-w-[1400px]' : 'max-w-none'")
|
||||
->and($applicationConfiguration)
|
||||
->not->toContain('max-w-[1180px]')
|
||||
->toContain('xl:gap-8')
|
||||
->and($serverConfiguration)
|
||||
->not->toContain('max-w-[1180px]')
|
||||
->toContain('xl:gap-8');
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('uses the custom tooltip for icon actions across layouts', function () {
|
||||
$baseLayout = file_get_contents(resource_path('views/layouts/base.blade.php'));
|
||||
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
|
||||
expect($baseLayout)->toContain('<x-icon-tooltip />')
|
||||
->and($tooltip)
|
||||
->toContain("closest('button, a, [data-tooltip]')")
|
||||
->toContain("target.matches('[data-tooltip], .icon-button')")
|
||||
->toContain("target.hasAttribute('aria-label')")
|
||||
->toContain('target.childElementCount === 1')
|
||||
->toContain("target.firstElementChild?.matches('svg')")
|
||||
->not->toContain("target.querySelector('svg')")
|
||||
->toContain("target.matches('[data-icon-tooltip-ignore]')")
|
||||
->toContain("target.removeAttribute('title')")
|
||||
->toContain('role="tooltip"')
|
||||
->toContain('aria-label')
|
||||
->toContain('fixed z-[100]');
|
||||
});
|
||||
|
||||
it('does not reposition an already active tooltip during nested mouseover events', function () {
|
||||
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
|
||||
expect($tooltip)
|
||||
->toContain('if (target === this.activeTarget && this.visible) return;');
|
||||
});
|
||||
|
||||
it('keeps a tooltip hidden until its measured position is applied', function () {
|
||||
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
|
||||
expect($tooltip)
|
||||
->toContain('positioned: false')
|
||||
->toContain('this.positioned = false;')
|
||||
->toContain('this.$nextTick(() => this.positioned = true);')
|
||||
->toContain("positioned ? 'visible' : 'invisible'");
|
||||
});
|
||||
|
||||
it('centers tooltips on the trigger and keeps them within the viewport', function () {
|
||||
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
|
||||
expect($tooltip)
|
||||
->toContain('this.x = rect.left + rect.width / 2;')
|
||||
->toContain('Math.min(window.innerWidth - width - 8, this.x - width / 2)')
|
||||
->not->toContain('-translate-x-1/2');
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('shows a loading overlay while resource selection steps load', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('wire:loading.class="pointer-events-none opacity-40 blur-[2px]"')
|
||||
->toContain('wire:loading.flex')
|
||||
->toContain('selectServer,selectDestination,selectProject,selectEnvironment')
|
||||
->toContain('Loading selection…')
|
||||
->toContain('isPaletteTransitioning')
|
||||
->toContain('runPaletteTransition')
|
||||
->toContain('Loading…');
|
||||
});
|
||||
|
||||
it('uses a single Alpine result renderer for every command palette result type', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('Create mode (server-rendered path)')
|
||||
->not->toContain('!$wire.isCreateMode')
|
||||
->toContain("<!-- Command palette -->\n <div x-cloak")
|
||||
->not->toContain("<!-- Command palette -->\n <template x-teleport=\"body\">")
|
||||
->toContain("<div wire:ignore>\n <template x-if=\"searchQuery.length")
|
||||
->toContain('x-for="(result, index) in searchResults"')
|
||||
->toContain('x-for="[categoryName, items] in Object.entries(groupedCreatableItems)"');
|
||||
});
|
||||
|
||||
it('skips hidden command palette results during keyboard navigation', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('filter(item => item.offsetParent !== null)');
|
||||
});
|
||||
|
||||
it('preselects the first result in every resource selection step', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('preselectFirstResult()')
|
||||
->toContain('this.selectedIndex = 0;')
|
||||
->toContain('results[0].focus();')
|
||||
->and(substr_count($view, 'x-init="preselectFirstResult()"'))->toBe(4);
|
||||
});
|
||||
|
||||
it('opens the command palette without changing page scrollbar visibility', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain("document.body.style.overflow = value ? 'hidden' : ''")
|
||||
->not->toContain("document.body.style.overflow = ''");
|
||||
});
|
||||
|
||||
it('animates the command palette with tw animate utilities', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
// Exit animations need fill-mode-forwards: without it the element snaps
|
||||
// back to full opacity when the keyframe animation ends, one frame before
|
||||
// Alpine applies display:none, which flashes the palette on close.
|
||||
expect($view)
|
||||
->toContain('<div x-show="modalOpen" @click="closeModal()"')
|
||||
->toContain('x-transition:enter="animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"')
|
||||
->toContain('x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100 fill-mode-forwards"')
|
||||
->toContain('x-transition:leave="animate-out fade-out-0 duration-100 fill-mode-forwards"')
|
||||
->not->toContain('<div x-show="modalOpen" x-cloak\n class="fixed inset-0');
|
||||
});
|
||||
|
||||
it('closes the client-side command palette without a Livewire request', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('$wire.closeSearchModal()')
|
||||
->not->toContain('closeTimer');
|
||||
});
|
||||
|
||||
it('closes the mobile sidebar when the command palette opens', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('@open-global-search.window="open = false"');
|
||||
});
|
||||
|
||||
it('keeps palette content intact during the close animation to prevent flicker', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
// closeModal() must only hide the palette immediately; content resets
|
||||
// (searchQuery, allSearchableItems) are deferred past the 100ms leave
|
||||
// animation so the panel does not collapse while fading out.
|
||||
expect($view)
|
||||
->toContain('clearTimeout(this.closeResetTimer);')
|
||||
->toContain("this.closeResetTimer = setTimeout(() => {\n this.isLoadingInitialData = false;")
|
||||
->toContain("this.searchQuery = '';\n this.allSearchableItems = [];");
|
||||
});
|
||||
|
||||
it('delays the header spinner so fast cached loads do not flash the icon', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('showLoadingSpinner')
|
||||
->toContain('this.spinnerTimer = setTimeout(')
|
||||
->toContain('x-show="!showLoadingSpinner"')
|
||||
->toContain('x-show="showLoadingSpinner"')
|
||||
->not->toContain(':class="isLoadingInitialData && \'is-loading\'"');
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Programmatic confirmation modals must not leave empty layout rows.
|
||||
*/
|
||||
test('modal confirmation roots are inline so hidden triggers do not stretch full width', function () {
|
||||
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
|
||||
|
||||
expect($modal)
|
||||
->toContain("'relative h-auto max-w-full'")
|
||||
->toContain("'inline-flex w-auto' => ! \$buttonFullWidth")
|
||||
->toContain("'flex w-full' => \$buttonFullWidth")
|
||||
->not->toContain("'relative h-auto'");
|
||||
});
|
||||
|
||||
test('server mobile proxy confirmations sit inside a display-none host', function () {
|
||||
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||
|
||||
expect($navbar)
|
||||
->toContain('server-mobile-restart-proxy-trigger')
|
||||
->toContain('server-mobile-stop-proxy-trigger')
|
||||
->toMatch('/class="hidden"[^>]*>\s*<x-modal-confirmation title="Confirm Proxy Restart/');
|
||||
});
|
||||
|
||||
test('application database and service programmatic modals are layout-hidden', function () {
|
||||
foreach ([
|
||||
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'),
|
||||
] as $path) {
|
||||
expect(file_get_contents($path))
|
||||
->toContain('class="hidden" aria-hidden="true"')
|
||||
->toContain('<x-slot:trigger>');
|
||||
}
|
||||
});
|
||||
|
||||
test('slide-over event shells use display contents', function () {
|
||||
expect(file_get_contents(resource_path('views/components/slide-over.blade.php')))
|
||||
->toContain("'class' => 'contents'");
|
||||
});
|
||||
|
||||
test('process dialog event shells use display contents', function () {
|
||||
expect(file_get_contents(resource_path('views/components/process-dialog.blade.php')))
|
||||
->toContain("'class' => 'contents'");
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('app css hides native webkit search cancel button to avoid double clear controls', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toContain('input[type="search"]::-webkit-search-cancel-button')
|
||||
->toContain('display: none');
|
||||
});
|
||||
|
||||
test('resource index keeps a single custom clear search control', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
|
||||
|
||||
expect(substr_count($view, 'aria-label="Clear search"'))->toBe(1)
|
||||
->and($view)->toContain('type="search"');
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('highlighted buttons use the shared coollabs style in every color scheme', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
$appStyles = file_get_contents(resource_path('css/app.css'));
|
||||
$views = collect(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(resource_path('views'))))
|
||||
->filter(fn (SplFileInfo $file): bool => $file->isFile() && $file->getExtension() === 'php')
|
||||
->map(fn (SplFileInfo $file): string => file_get_contents($file->getPathname()))
|
||||
->implode("\n");
|
||||
|
||||
expect($utilities)
|
||||
->toContain('@utility button-highlighted')
|
||||
->toContain('@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-accent-foreground! hover:from-coollabs-100 hover:to-coollabs hover:text-accent-foreground!;')
|
||||
->and($appStyles)
|
||||
->toContain('button[isHighlighted]:not(:disabled)')
|
||||
->toContain('@apply button-highlighted;')
|
||||
->and($views)
|
||||
->not->toContain('dark:bg-warning/15! dark:text-warning! dark:ring-warning/25');
|
||||
});
|
||||
|
||||
test('custom theme highlighted buttons use the computed contrasting foreground', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
$appStyles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($utilities)
|
||||
->toContain('text-accent-foreground!')
|
||||
->toContain('hover:text-accent-foreground!')
|
||||
->not->toContain('text-white! hover:')
|
||||
->and($appStyles)
|
||||
->toContain('html[data-theme="custom"] .button-highlighted .animate-spin')
|
||||
->toContain('html[data-theme="custom"] button[isHighlighted] .animate-spin');
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('registers the upload reicon used by import backup navigation', function () {
|
||||
$path = resource_path('views/components/reicon.blade.php');
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)->toContain("'upload' => ");
|
||||
|
||||
$html = Blade::render('<x-reicon name="upload" class="menu-item-icon" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('viewBox="0 0 24 24"')
|
||||
->toContain('fill="currentColor"')
|
||||
->toContain('menu-item-icon')
|
||||
->not->toBe('<svg class="menu-item-icon size-4" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
|
||||
</svg>');
|
||||
});
|
||||
|
||||
it('uses the upload reicon for import backup in database configuration nav', function () {
|
||||
$contents = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain("'label' => 'Import Backup'")
|
||||
->toContain("'icon' => 'upload'");
|
||||
});
|
||||
|
||||
it('uses the upload reicon for import backup in service navigation', function () {
|
||||
$contents = file_get_contents(resource_path('views/components/service/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain("'label' => 'Import Backup'")
|
||||
->toContain("'icon' => 'upload'")
|
||||
->toContain("'route' => 'project.service.import-backup'");
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('provides search and persisted grid and table views for infrastructure lists', function (string $view, string $item, string $storageKey) {
|
||||
$contents = file_get_contents(resource_path($view));
|
||||
|
||||
expect($contents)
|
||||
->toContain("'placeholder' => 'Search {$item}'")
|
||||
->toContain("localStorage.getItem('{$storageKey}') || 'table'")
|
||||
->toContain("localStorage.setItem('{$storageKey}', mode)")
|
||||
->toContain("@include('livewire.shared.list-search-empty'");
|
||||
})->with([
|
||||
'sources' => ['views/source/all.blade.php', 'sources', 'coolify-sources-view'],
|
||||
'destinations' => ['views/livewire/destination/index.blade.php', 'destinations', 'coolify-destinations-view'],
|
||||
'S3 storages' => ['views/livewire/storage/index.blade.php', 'S3 storages', 'coolify-s3-storages-view'],
|
||||
]);
|
||||
|
||||
it('renders shared search controls with result counts and view switchers', function () {
|
||||
$controls = file_get_contents(resource_path('views/livewire/shared/list-search-controls.blade.php'));
|
||||
$emptyState = file_get_contents(resource_path('views/livewire/shared/list-search-empty.blade.php'));
|
||||
|
||||
expect($controls)
|
||||
->toContain('x-model.debounce.150ms="search"')
|
||||
->toContain('filteredItems.length')
|
||||
->toContain("setViewMode('table')")
|
||||
->toContain("setViewMode('grid')")
|
||||
->toContain('aria-label="Table view"')
|
||||
->toContain('aria-label="Grid view"')
|
||||
->toContain('control-selected')
|
||||
->and($emptyState)->toContain('filteredItems.length === 0');
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Layout popups (realtime warning, sponsorship, notifications) use the redesigned UI shell.
|
||||
*/
|
||||
test('realtime connection warning uses the redesigned popup shell', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/layout-popups.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('Cannot connect to real-time service')
|
||||
->toContain('Acknowledge & disable')
|
||||
->toContain('customActions')
|
||||
->toContain('rounded-2xl border border-red-200')
|
||||
->toContain('name="alert-triangle"')
|
||||
->not->toContain('WARNING: </span> Cannot connect to real-time service')
|
||||
->not->toContain('Acknowledge & Disable This Popup');
|
||||
});
|
||||
|
||||
test('notification reminder uses the redesigned popup shell', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/layout-popups.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('No notifications enabled')
|
||||
->toContain('Accept and close')
|
||||
->toContain('Open notifications')
|
||||
->not->toContain('Accept and Close');
|
||||
});
|
||||
|
||||
test('non-critical reminders collapse after ten seconds', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/layout-popups.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('reminderCollapseAfter: 10000')
|
||||
->toContain("scheduleReminderCollapse('sponsorship')")
|
||||
->toContain("scheduleReminderCollapse('notification')")
|
||||
->toContain('reminders.sponsorship.compact = true')
|
||||
->toContain('reminders.notification.compact = true');
|
||||
});
|
||||
|
||||
test('sponsorship reminder is disabled in development', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/layout-popups.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('this.popups.sponsorship = !this.isDevelopment && this.shouldShowMonthlyPopup')
|
||||
->toContain('x-on:show-sponsorship-reminder.window="popups.sponsorship = true')
|
||||
->toContain('x-on:show-sponsorship-reminder.window="bannerVisible = true"');
|
||||
});
|
||||
|
||||
test('sponsorship reminder listener is not parsed as a blade directive', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/layout-popups.blade.php'));
|
||||
$compiledView = Blade::compileString($view);
|
||||
|
||||
expect($compiledView)
|
||||
->toContain('x-on:show-sponsorship-reminder.window=')
|
||||
->not->toContain('$__env->yieldSection()');
|
||||
});
|
||||
|
||||
test('advanced settings provides a development-only sponsorship reminder preview', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/settings/advanced.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('@if (isDev())')
|
||||
->toContain("@click=\"\$dispatch('show-sponsorship-reminder')\"")
|
||||
->toContain('Show sponsorship reminder');
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('native scrollbars follow the active theme', function () {
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($styles)
|
||||
->toContain('html {')
|
||||
->toContain('color-scheme: light;')
|
||||
->toContain('html.dark {')
|
||||
->toContain('color-scheme: dark;');
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
test('listbox trigger styles constrain width and ellipsize long labels', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toContain('.listbox-trigger {')
|
||||
->toContain('min-width: 0;')
|
||||
->toContain('overflow: hidden;')
|
||||
->toContain('.listbox-trigger-label {')
|
||||
->toContain('text-overflow: ellipsis;')
|
||||
->toContain('white-space: nowrap;');
|
||||
});
|
||||
|
||||
test('listbox trigger height matches shared inputs', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toMatch('/\.listbox-trigger \{[^}]*height: 2\.25rem;/s')
|
||||
->toMatch('/\.application-settings-workspace \.listbox-trigger[^}]*height: 2rem;/s');
|
||||
});
|
||||
|
||||
test('disabled listboxes use the same colors as disabled inputs', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->toMatch('/\.listbox-trigger:disabled \{[^}]*background-color: var\(--color-neutral-100\);[^}]*color: var\(--color-neutral-400\);/s')
|
||||
->toMatch('/\.dark \.listbox-trigger:disabled \{[^}]*background-color: color-mix\(in oklab, var\(--color-white\) 3%, transparent\);[^}]*color: var\(--color-fg-faint\);/s')
|
||||
->not->toMatch('/\.listbox-trigger:disabled \{[^}]*opacity:/s');
|
||||
});
|
||||
|
||||
test('listbox component uses shared trigger label truncation', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-forms.listbox id="longOption" label="Example"
|
||||
:options="[
|
||||
['value' => 'long', 'label' => 'A very long option label that should truncate'],
|
||||
]" :wire="false" value="long" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('class="w-full min-w-0"')
|
||||
->toContain('relative min-w-0')
|
||||
->toContain('listbox-trigger')
|
||||
->toContain('listbox-trigger-label')
|
||||
->toContain(':title="current"')
|
||||
->not->toContain('class="truncate" x-text="current"');
|
||||
});
|
||||
|
||||
test('listboxes stay in their local Alpine scope by default', function () {
|
||||
$component = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
$html = Blade::render('<x-forms.listbox id="region" :options="[]" :wire="false" />');
|
||||
|
||||
expect($component)->toContain("'portal' => false")
|
||||
->and($html)
|
||||
->toContain('x-data="{')
|
||||
->toContain('x-ref="panel"')
|
||||
->not->toContain('x-teleport="body"')
|
||||
->not->toContain('floatingDropdown(')
|
||||
->not->toContain('style="position: fixed');
|
||||
});
|
||||
|
||||
test('mobile listbox panels stay anchored to their trigger', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
->not->toContain('.listbox-panel:not([style*="position: fixed"])')
|
||||
->not->toContain('transform: translate(-50%, -50%) !important;');
|
||||
});
|
||||
|
||||
test('portaled listboxes measure content without inheriting the viewport width', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain("panel.style.width = 'max-content';")
|
||||
->toContain('panel.style.minWidth = `${triggerRect.width}px`;')
|
||||
->toContain('Math.max(triggerRect.width, panel.offsetWidth)');
|
||||
});
|
||||
|
||||
test('portaled listboxes stay anchored when a scroll container moves', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain('@scroll.window.capture="open && positionPanel()"');
|
||||
});
|
||||
|
||||
test('searchable listbox component uses shared trigger label truncation', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-forms.searchable-listbox id="tz" label="Timezone"
|
||||
:options="[['value' => 'UTC', 'label' => 'UTC']]" :wire="false" value="UTC" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('class="w-full min-w-0"')
|
||||
->toContain('relative min-w-0')
|
||||
->toContain('listbox-trigger-label')
|
||||
->toContain(':title="current"');
|
||||
});
|
||||
|
||||
test('listbox shows an empty state when it has no options', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
$operations = file_get_contents(resource_path('views/livewire/project/shared/resource-operations.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain("'emptyText' => 'No options available.'")
|
||||
->toContain('x-show="options.length === 0"');
|
||||
|
||||
expect($operations)->toContain('No network destinations are available on this server.');
|
||||
});
|
||||
|
||||
test('listbox forwards dynamic disabled state to its trigger', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
$operations = file_get_contents(resource_path('views/livewire/project/shared/resource-operations.blade.php'));
|
||||
|
||||
expect($listbox)->toContain("\$attributes->whereStartsWith('x-bind:disabled')");
|
||||
|
||||
expect($operations)
|
||||
->toContain('selectedCloneServer: null')
|
||||
->toContain('selectedCloneProject: null')
|
||||
->toContain('selectedCloneEnvironment: null')
|
||||
->toContain('currentServerId: @js($resource->destination->server->id)')
|
||||
->toContain('currentDestinationUuid: @js($resource->destination->uuid)')
|
||||
->toContain("server.id == this.currentServerId ? ' (current)' : ''")
|
||||
->toContain("destination.uuid == this.currentDestinationUuid ? ' (current)' : ''")
|
||||
->toContain('selectedCloneServer = null;')
|
||||
->toContain('placeholder="Choose a server…"')
|
||||
->toContain("this.selectedCloneServer === null || this.selectedCloneServer === ''")
|
||||
->toContain("x-bind:disabled=\"selectedCloneServer === null || selectedCloneServer === ''\"")
|
||||
->toContain('x-model="selectedCloneProject"')
|
||||
->toContain('x-model="selectedCloneEnvironment"')
|
||||
->toContain('$wire.cloneTo(selectedCloneDestination)')
|
||||
->toContain('$wire.cloneTo(currentDestinationUuid, selectedCloneEnvironment)')
|
||||
->not->toContain('$wire.cloneTo(@js(')
|
||||
->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"');
|
||||
});
|
||||
|
||||
test('listbox waits for change handlers and prevents overlapping selections', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain('saving: false')
|
||||
->toContain('async choose(option)')
|
||||
->toContain('await this.$wire.')
|
||||
->toContain('if (option.header || option.disabled || this.saving) return;')
|
||||
->toContain("'pointer-events-none opacity-70': saving");
|
||||
});
|
||||
|
||||
test('listbox does not send a second live entangle request when using a change handler', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)->toContain('@elseif ($live && ! $onChange) @entangle($id).live');
|
||||
});
|
||||
|
||||
test('listbox can preserve its client value across Livewire morphs', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain("'preserveValue' => false")
|
||||
->toContain('@if ($preserveValue) wire:ignore @endif');
|
||||
});
|
||||
|
||||
test('notification event multiselect truncates long selected summaries', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-notification.event-multiselect id="server-slack-events" label="Servers" :events="[
|
||||
['property' => 'dockerCleanupFailureSlackNotifications', 'label' => 'Docker cleanup failure', 'enabled' => true],
|
||||
['property' => 'serverDiskUsageSlackNotifications', 'label' => 'Disk usage warning', 'enabled' => true],
|
||||
['property' => 'serverUnreachableSlackNotifications', 'label' => 'Server unreachable', 'enabled' => true],
|
||||
['property' => 'serverPatchSlackNotifications', 'label' => 'Server patching', 'enabled' => true],
|
||||
]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('class="w-full min-w-0"')
|
||||
->toContain('relative min-w-0')
|
||||
->toContain('listbox-trigger')
|
||||
->toContain('listbox-trigger-label')
|
||||
->toContain('Docker cleanup failure, Disk usage warning, Server unreachable, Server patching')
|
||||
->toContain('title="Docker cleanup failure, Disk usage warning, Server unreachable, Server patching"')
|
||||
->toContain('4/4');
|
||||
});
|
||||
|
||||
test('notification event multiselect uses the same panel animation as listboxes', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-notification.event-multiselect id="deployment-email-events" label="Deployments" :events="[
|
||||
['property' => 'deploymentSuccessEmailNotifications', 'label' => 'Deployment success', 'enabled' => false],
|
||||
['property' => 'deploymentFailureEmailNotifications', 'label' => 'Deployment failure', 'enabled' => true],
|
||||
['property' => 'statusChangeEmailNotifications', 'label' => 'Container status changes', 'enabled' => false],
|
||||
]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('x-transition:enter="transition ease-out duration-100"')
|
||||
->toContain('x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"')
|
||||
->toContain('x-transition:enter-end="opacity-100 translate-y-0 scale-100"')
|
||||
->toContain('x-transition:leave="transition ease-in duration-75"')
|
||||
->toContain('x-transition:leave-start="opacity-100 translate-y-0 scale-100"')
|
||||
->toContain('x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"');
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('keeps sentinel restarted events from re-syncing editable form fields', function () {
|
||||
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
|
||||
|
||||
preg_match('/public function handleSentinelRestarted\([^)]*\)\s*\{(?<body>.*?)\n \}/s', $componentSource, $matches);
|
||||
|
||||
expect($matches['body'] ?? '')
|
||||
->toContain('$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;')
|
||||
->not->toContain('$this->syncData();');
|
||||
});
|
||||
|
||||
it('refreshes display state after sentinel synchronization without changing editable fields', function () {
|
||||
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
|
||||
|
||||
expect($componentSource)
|
||||
->toContain('SentinelSynchronized" => \'handleSentinelSynchronized\'');
|
||||
|
||||
preg_match('/public function handleSentinelSynchronized\([^)]*\)(?:: [^{]+)?\s*\{(?<body>.*?)\n \}/s', $componentSource, $matches);
|
||||
|
||||
expect($matches['body'] ?? '')
|
||||
->toContain('$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;')
|
||||
->not->toContain('$this->syncData();');
|
||||
});
|
||||
|
||||
it('does not expose a Sentinel disable action', function () {
|
||||
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
|
||||
|
||||
expect($componentSource)->not->toContain('function toggleSentinel')
|
||||
->and($view)->not->toContain('Disable')
|
||||
->and($view)->not->toContain('Enable Sentinel');
|
||||
});
|
||||
|
||||
it('does not repeat a disabled status badge in the sentinel empty state', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
|
||||
|
||||
expect($view)->not->toContain("? 'Disabled'");
|
||||
});
|
||||
|
||||
it('shows the connected sentinel state without a decorative icon or redundant heading', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('Sentinel is connected and reporting server health to this Coolify instance.')
|
||||
->not->toContain('Health reporting active')
|
||||
->not->toContain('<x-reicon name="dashboard"');
|
||||
});
|
||||
|
||||
it('tells the user that saving sentinel settings initiates a restart', function () {
|
||||
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
|
||||
|
||||
preg_match('/public function submit\([^)]*\).*?\{(?<body>.*?)\n \}/s', $componentSource, $matches);
|
||||
|
||||
expect($matches['body'] ?? '')
|
||||
->toContain("\$this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');");
|
||||
});
|
||||
|
||||
it('starts mandatory Sentinel after server validation succeeds', function () {
|
||||
$interactiveValidation = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php'));
|
||||
$queuedValidation = file_get_contents(app_path('Jobs/ValidateAndInstallServerJob.php'));
|
||||
|
||||
expect($interactiveValidation)->toContain('CheckAndStartSentinelJob::dispatch($this->server);')
|
||||
->and($queuedValidation)->toContain('CheckAndStartSentinelJob::dispatch($this->server);');
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
test('loading indicators use warning yellow throughout dark mode', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
$loading = file_get_contents(resource_path('views/components/loading.blade.php'));
|
||||
$pageLoading = file_get_contents(resource_path('views/components/page-loading.blade.php'));
|
||||
expect($utilities)
|
||||
->toContain('@utility loading-indicator')
|
||||
->toContain('@apply text-coollabs dark:text-warning;')
|
||||
->and($loading)->toContain('loading-indicator')
|
||||
->and($pageLoading)->toContain('loading-indicator')
|
||||
->and($appCss)->toContain('.dark .animate-spin')
|
||||
->toContain('color: var(--color-warning) !important;')
|
||||
->toContain('.dark #nprogress .bar')
|
||||
->toContain('background: var(--color-warning) !important;');
|
||||
});
|
||||
|
||||
test('livewire navigation progress bar uses coollabs purple', function () {
|
||||
expect(config('livewire.navigate.progress_bar_color'))->toBe('#6b16ed');
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('uses the current neutral loading indicator styles', function () {
|
||||
$loading = file_get_contents(resource_path('views/components/loading.blade.php'));
|
||||
|
||||
expect($loading)
|
||||
->toContain('gap-2 text-[13px] text-neutral-500 dark:text-fg-dim')
|
||||
->toContain("'size-3' => \$compact")
|
||||
->toContain("'size-4' => ! \$compact")
|
||||
->toContain('stroke="currentColor" stroke-width="2"')
|
||||
->not->toContain('text-coollabs')
|
||||
->not->toContain('dark:text-warning');
|
||||
});
|
||||
|
||||
it('shows runtime container loading inside a settings card', function () {
|
||||
$logs = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php'));
|
||||
|
||||
expect($logs)
|
||||
->toContain('application-settings-section-body flex min-h-40 w-full items-center justify-center')
|
||||
->toContain('<x-loading text="Loading containers" />');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user