mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
feat: Add domains sidebar view for simple applications
Implement a dedicated Domains management view for non-Docker Compose applications with: - Separate Domains sidebar item under application configuration - List-based domain management (add/remove individual domains) - Three-field domain input (scheme, domain, port) with searchable dropdown for schemes - Support for custom protocols (tcp, grpc, ftp, ssh, etc.) via x-forms.datalist component - Generate random domain functionality - DNS validation per domain - Domain conflict detection - Modal dialogs for adding/editing domains matching Coolify's design patterns - Authorization checks using AuthorizesRequests trait - Read-only domain display in General view for simple applications Features: - DomainAdd component: Creates new domains via searchable scheme dropdown - DomainEdit component: Parses and updates existing domains - Domains component: Manages domain list with add/edit/remove/refresh actions - Backend stores domains as comma-separated strings in applications.fqdn column - Frontend UI uses dashboard box styling with proper dark mode support Documentation: - Added x-forms.datalist component documentation to CLAUDE.md - Added Domain Management section with architecture details - Documented DomainAdd/DomainEdit components and workflow 🧠 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
083d745d70
commit
3a08d26dc9
10 files changed
+795
-51
No files matched your search
@@ -353,6 +353,138 @@ This file contains high-level guidelines for Claude Code. For **more detailed, t
|
||||
- Use `gap` utilities for spacing, not margins
|
||||
|
||||
|
||||
## UI Components
|
||||
|
||||
### Available Blade Components
|
||||
|
||||
#### Callout Component
|
||||
Use `x-callout` for alert/warning/info messages:
|
||||
|
||||
```html
|
||||
<x-callout type="warning" title="Warning Title">
|
||||
Your message here
|
||||
</x-callout>
|
||||
```
|
||||
|
||||
**Available types**: `warning`, `danger`, `info`, `success`
|
||||
|
||||
**DO NOT use**: `x-forms.alert` (does not exist)
|
||||
|
||||
**Example usage**:
|
||||
```html
|
||||
<x-callout type="warning" title="Note" class="mb-4">
|
||||
This view is for simple applications only.
|
||||
</x-callout>
|
||||
```
|
||||
|
||||
The callout component is located at `resources/views/components/callout.blade.php` and provides:
|
||||
- Automatic icon selection based on type
|
||||
- Color-coded styling (yellow for warning, red for danger, blue for info, green for success)
|
||||
- Dark mode support
|
||||
- Consistent padding and border styling
|
||||
|
||||
#### Datalist Component (Searchable Dropdown)
|
||||
Use `x-forms.datalist` for searchable dropdowns with custom input capability:
|
||||
|
||||
```html
|
||||
<x-forms.datalist wire:model="scheme" label="Scheme" placeholder="Select or type" required
|
||||
helper="Select from options or type a custom value">
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="wss">WSS (WebSocket Secure)</option>
|
||||
<option value="ws">WS (WebSocket)</option>
|
||||
</x-forms.datalist>
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- **Searchable**: Filter options by typing
|
||||
- **Custom input**: Users can type values not in the list
|
||||
- **Single or multiple**: Set `:multiple="true"` for multi-select
|
||||
- **Keyboard navigation**: Full keyboard support
|
||||
- **Visual feedback**: Shows selected state and loading indicators
|
||||
- **Dark mode support**: Automatic theming
|
||||
|
||||
**When to use**:
|
||||
- ✅ User needs to search through many options (e.g., server selection, SSH keys)
|
||||
- ✅ Custom input is allowed/encouraged (e.g., protocol schemes: http, https, custom protocols)
|
||||
- ✅ Multi-select functionality needed
|
||||
- ❌ Simple yes/no or 2-3 options → use `x-forms.select` instead
|
||||
|
||||
**DO NOT use**: HTML5 `<datalist>` directly or `list=""` attribute on inputs. Always use `x-forms.datalist` component.
|
||||
|
||||
**Example - Protocol scheme selector**:
|
||||
```html
|
||||
{{-- Provides common schemes but allows custom (tcp, grpc, ftp, etc.) --}}
|
||||
<x-forms.datalist wire:model="scheme" label="Scheme" placeholder="Select or type a scheme" required
|
||||
helper="Select from common schemes or type a custom one (e.g., tcp, grpc, ftp)">
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="wss">WSS (WebSocket Secure)</option>
|
||||
<option value="ws">WS (WebSocket)</option>
|
||||
</x-forms.datalist>
|
||||
```
|
||||
|
||||
**Example - Multi-select SSH keys**:
|
||||
```html
|
||||
<x-forms.datalist label="Additional SSH Keys" wire:model="selectedSshKeyIds"
|
||||
:multiple="true" placeholder="Select SSH keys...">
|
||||
@foreach ($sshKeys as $key)
|
||||
<option value="{{ $key->id }}">{{ $key->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
```
|
||||
|
||||
The datalist component is located at `resources/views/components/forms/datalist.blade.php` and provides:
|
||||
- Alpine.js-powered search and filtering
|
||||
- Livewire integration with `@entangle` for reactive state
|
||||
- Visual tags for multi-select mode
|
||||
- Dropdown with hover effects
|
||||
- Handles both string and integer values
|
||||
- Required/readonly/disabled states
|
||||
|
||||
## Domain Management
|
||||
|
||||
### Application Domains
|
||||
- Domains are stored as **comma-separated strings** in the `applications.fqdn` column (type: `longText`, nullable)
|
||||
- The `Application` model has an `fqdns()` accessor that converts the comma-separated string to an array
|
||||
- Example: `"http://app.coolify.io,https://cloud.coolify.io/dashboard"`
|
||||
|
||||
### New Domains View (Simple Applications)
|
||||
A dedicated Domains management view is available for non-Docker Compose applications:
|
||||
- **Route**: `/domains` under application configuration
|
||||
- **Component**: `App\Livewire\Project\Application\Domains`
|
||||
- **View**: `resources/views/livewire/project/application/domains.blade.php`
|
||||
- **Sidebar**: Only shown when `$application->build_pack !== 'dockercompose'`
|
||||
|
||||
**Features**:
|
||||
- List-based domain management (add/remove individual domains)
|
||||
- Generate random domain functionality
|
||||
- DNS validation per domain
|
||||
- Domain conflict detection
|
||||
- Redirect settings (www/non-www)
|
||||
- Authorization using `AuthorizesRequests` trait
|
||||
- **Three-field domain input**: Scheme (searchable dropdown), Domain (text), Port (optional number)
|
||||
- Scheme uses `x-forms.datalist` component for searchable dropdown with custom input
|
||||
- Supports common protocols (http, https, ws, wss) and custom protocols (tcp, grpc, ftp, ssh, etc.)
|
||||
- Backend stores as single URL string: `scheme://domain:port`
|
||||
|
||||
**Domain Add/Edit Components**:
|
||||
- **DomainAdd**: `App\Livewire\Project\Application\DomainAdd`
|
||||
- Properties: `$scheme`, `$domain`, `$port`
|
||||
- Combines fields into full URL: `$scheme . '://' . $domain . ':' . $port`
|
||||
- Validation: `alpha_dash` for scheme (allows any alphanumeric + dash/underscore)
|
||||
- Uses modal pattern with `x-modal-input` component
|
||||
|
||||
- **DomainEdit**: `App\Livewire\Project\Application\DomainEdit`
|
||||
- Parses existing URL using PHP's `parse_url()`
|
||||
- Extracts scheme, host (with path), and port into separate fields
|
||||
- Updates domain at specific index in array
|
||||
|
||||
**General View Changes**:
|
||||
- Domain fields in General view are now read-only for simple applications
|
||||
- Displays current domains with a "Manage domains →" link to the new Domains view
|
||||
- Docker Compose applications continue to manage domains per service in the General view
|
||||
|
||||
Random other things you should remember:
|
||||
- App\Models\Application::team must return a relationship instance., always use team()
|
||||
- Always use `Model::ownedByCurrentTeamCached()` instead of `Model::ownedByCurrentTeam()->get()` for team-scoped queries to avoid duplicate database queries
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class DomainAdd extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Application $application;
|
||||
|
||||
public string $scheme = 'https';
|
||||
|
||||
public string $domain = '';
|
||||
|
||||
public string $port = '';
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$this->validate([
|
||||
'scheme' => 'required|string|alpha_dash',
|
||||
'domain' => 'required|string',
|
||||
'port' => 'nullable|integer|min:1|max:65535',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Build the full domain URL
|
||||
$fullDomain = $this->scheme.'://'.trim($this->domain);
|
||||
|
||||
// Add port if specified
|
||||
if (! empty($this->port)) {
|
||||
$fullDomain .= ':'.$this->port;
|
||||
}
|
||||
|
||||
// Clean and validate
|
||||
$fullDomain = str($fullDomain)->replaceEnd(',', '')->trim()->toString();
|
||||
$fullDomain = str($fullDomain)->replaceStart(',', '')->trim()->toString();
|
||||
|
||||
// Validate URL format (allow any scheme)
|
||||
try {
|
||||
Url::fromString($fullDomain);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Invalid URL format.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$fullDomain = str($fullDomain)->lower()->toString();
|
||||
|
||||
// Get current domains
|
||||
$currentDomains = $this->application->fqdn
|
||||
? array_values(array_filter(explode(',', $this->application->fqdn)))
|
||||
: [];
|
||||
|
||||
// Check if domain already exists
|
||||
if (in_array($fullDomain, $currentDomains)) {
|
||||
$this->dispatch('error', 'Domain already exists in the list.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to domains array
|
||||
$currentDomains[] = $fullDomain;
|
||||
|
||||
// Check for domain conflicts
|
||||
$this->application->fqdn = implode(',', $currentDomains);
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->dispatch('domainConflict', [
|
||||
'conflicts' => $result['conflicts'],
|
||||
'newDomain' => $fullDomain,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to database
|
||||
$this->application->save();
|
||||
|
||||
// Reset default labels
|
||||
if ($this->application->settings->is_container_label_readonly_enabled) {
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
|
||||
$this->application->custom_labels = base64_encode($customLabels);
|
||||
$this->application->save();
|
||||
}
|
||||
|
||||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', 'Domain added successfully.');
|
||||
$this->dispatch('modalClosed');
|
||||
$this->dispatch('refreshDomains');
|
||||
|
||||
// Reset inputs
|
||||
$this->scheme = 'https';
|
||||
$this->domain = '';
|
||||
$this->port = '';
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.domain-add');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class DomainEdit extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Application $application;
|
||||
|
||||
public int $index;
|
||||
|
||||
public string $domain;
|
||||
|
||||
public string $scheme = 'https';
|
||||
|
||||
public string $domainName = '';
|
||||
|
||||
public string $port = '';
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
// Parse the existing domain into scheme, domain, and port
|
||||
$this->parseDomain($this->domain);
|
||||
}
|
||||
|
||||
protected function parseDomain(string $fullDomain)
|
||||
{
|
||||
try {
|
||||
$url = parse_url($fullDomain);
|
||||
|
||||
// Extract scheme
|
||||
$this->scheme = $url['scheme'] ?? 'https';
|
||||
|
||||
// Extract host/path
|
||||
$this->domainName = $url['host'] ?? '';
|
||||
if (isset($url['path']) && $url['path'] !== '/') {
|
||||
$this->domainName .= $url['path'];
|
||||
}
|
||||
|
||||
// Extract port
|
||||
$this->port = isset($url['port']) ? (string) $url['port'] : '';
|
||||
} catch (\Throwable $e) {
|
||||
// If parsing fails, just use the domain as-is
|
||||
$this->domainName = $fullDomain;
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$this->validate([
|
||||
'scheme' => 'required|string|alpha_dash',
|
||||
'domainName' => 'required|string',
|
||||
'port' => 'nullable|integer|min:1|max:65535',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Build the full domain URL
|
||||
$newDomain = $this->scheme.'://'.trim($this->domainName);
|
||||
|
||||
// Add port if specified
|
||||
if (! empty($this->port)) {
|
||||
$newDomain .= ':'.$this->port;
|
||||
}
|
||||
|
||||
// Clean and validate
|
||||
$newDomain = str($newDomain)->replaceEnd(',', '')->trim()->toString();
|
||||
$newDomain = str($newDomain)->replaceStart(',', '')->trim()->toString();
|
||||
|
||||
// Validate URL format (allow any scheme)
|
||||
try {
|
||||
Url::fromString($newDomain);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Invalid URL format.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$newDomain = str($newDomain)->lower()->toString();
|
||||
|
||||
// Get current domains
|
||||
$currentDomains = $this->application->fqdn
|
||||
? array_values(array_filter(explode(',', $this->application->fqdn)))
|
||||
: [];
|
||||
|
||||
// Check if domain already exists (but not the one being edited)
|
||||
$existingDomains = $currentDomains;
|
||||
unset($existingDomains[$this->index]);
|
||||
if (in_array($newDomain, $existingDomains)) {
|
||||
$this->dispatch('error', 'Domain already exists in the list.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the domain at the specified index
|
||||
$currentDomains[$this->index] = $newDomain;
|
||||
|
||||
// Check for domain conflicts
|
||||
$this->application->fqdn = implode(',', $currentDomains);
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->dispatch('domainConflict', [
|
||||
'conflicts' => $result['conflicts'],
|
||||
'editedDomain' => $newDomain,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to database
|
||||
$this->application->save();
|
||||
|
||||
// Reset default labels
|
||||
if ($this->application->settings->is_container_label_readonly_enabled) {
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
|
||||
$this->application->custom_labels = base64_encode($customLabels);
|
||||
$this->application->save();
|
||||
}
|
||||
|
||||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', 'Domain updated successfully.');
|
||||
$this->dispatch('modalClosed');
|
||||
$this->dispatch('refreshDomains');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.domain-edit');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Domains extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Application $application;
|
||||
|
||||
public array $domains = [];
|
||||
|
||||
public $domainConflicts = [];
|
||||
|
||||
public $showDomainConflictModal = false;
|
||||
|
||||
public $forceSaveDomains = false;
|
||||
|
||||
public string $redirect;
|
||||
|
||||
protected $listeners = [
|
||||
'confirmDomainUsage',
|
||||
'refreshDomains' => 'loadDomains',
|
||||
'configurationChanged' => '$refresh',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
|
||||
$this->redirect = $this->application->redirect;
|
||||
$this->loadDomains();
|
||||
}
|
||||
|
||||
public function loadDomains(): void
|
||||
{
|
||||
$this->application->refresh();
|
||||
if ($this->application->fqdn) {
|
||||
$this->domains = array_values(array_filter(
|
||||
explode(',', $this->application->fqdn),
|
||||
fn ($domain) => ! empty(trim($domain))
|
||||
));
|
||||
} else {
|
||||
$this->domains = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDomain(int $index)
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
try {
|
||||
if (isset($this->domains[$index])) {
|
||||
unset($this->domains[$index]);
|
||||
$this->domains = array_values($this->domains); // Re-index array
|
||||
|
||||
$this->saveDomains();
|
||||
|
||||
$this->dispatch('success', 'Domain removed successfully.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function generateDomain()
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
try {
|
||||
$server = data_get($this->application, 'destination.server');
|
||||
if ($server) {
|
||||
$uuid = new Cuid2;
|
||||
$fqdn = generateUrl(server: $server, random: $uuid);
|
||||
|
||||
// Check if domain already exists
|
||||
if (in_array($fqdn, $this->domains)) {
|
||||
$this->dispatch('error', 'Generated domain already exists.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->domains[] = $fqdn;
|
||||
|
||||
if (! $this->saveDomains()) {
|
||||
return; // Stop if there are conflicts
|
||||
}
|
||||
|
||||
$this->dispatch('success', 'Domain generated successfully.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkDNS(string $domain)
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
|
||||
try {
|
||||
if ($this->application->additional_servers->count() === 0) {
|
||||
if (! validateDNSEntry($domain, $this->application->destination->server)) {
|
||||
$this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
||||
} else {
|
||||
$this->dispatch('success', 'DNS validation successful.');
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
protected function saveDomains(): bool
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
// Convert array back to comma-separated string
|
||||
$fqdn = implode(',', array_filter($this->domains));
|
||||
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains && ! empty($fqdn)) {
|
||||
$this->application->fqdn = $fqdn;
|
||||
$result = checkDomainUsage(resource: $this->application);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
|
||||
// Save to database
|
||||
$this->application->fqdn = $fqdn ?: null;
|
||||
$this->application->save();
|
||||
|
||||
// Reset default labels
|
||||
$this->resetDefaultLabels();
|
||||
|
||||
// Reload domains to ensure consistency
|
||||
$this->application->refresh();
|
||||
$this->loadDomains();
|
||||
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
{
|
||||
$this->forceSaveDomains = true;
|
||||
$this->showDomainConflictModal = false;
|
||||
$this->saveDomains();
|
||||
}
|
||||
|
||||
public function setRedirect()
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
try {
|
||||
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
|
||||
if ($has_www === 0 && $this->redirect === 'www') {
|
||||
$this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list and as an A DNS record (if applicable).');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->application->redirect = $this->redirect;
|
||||
$this->application->save();
|
||||
$this->resetDefaultLabels();
|
||||
$this->dispatch('success', 'Redirect updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
protected function resetDefaultLabels()
|
||||
{
|
||||
try {
|
||||
if ($this->application->settings->is_container_label_readonly_enabled) {
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
|
||||
$this->application->custom_labels = base64_encode($customLabels);
|
||||
$this->application->save();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Silent fail for label regeneration
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.domains');
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@
|
||||
<div class="flex flex-col items-start gap-2 min-w-fit">
|
||||
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||
href="{{ route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">General</a>
|
||||
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
<a class='menu-item' wire:current.exact="menu-item-active"
|
||||
href="{{ route('project.application.domains', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Domains</a>
|
||||
@endif
|
||||
<a class='menu-item' wire:current.exact="menu-item-active"
|
||||
href="{{ route('project.application.advanced', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Advanced</a>
|
||||
@if ($application->destination->server->isSwarm())
|
||||
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||
@@ -79,6 +83,8 @@
|
||||
<div class="w-full">
|
||||
@if ($currentRoute === 'project.application.configuration')
|
||||
<livewire:project.application.general :application="$application" />
|
||||
@elseif ($currentRoute === 'project.application.domains' && $application->build_pack !== 'dockercompose')
|
||||
<livewire:project.application.domains :application="$application" />
|
||||
@elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm())
|
||||
<livewire:project.application.swarm :application="$application" />
|
||||
@elseif ($currentRoute === 'project.application.advanced')
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submit'>
|
||||
<x-forms.datalist wire:model="scheme" label="Scheme" placeholder="Select or type a scheme" required
|
||||
helper="Select from common schemes or type a custom one (e.g., tcp, grpc, ftp)">
|
||||
<option value="https">https</option>
|
||||
<option value="http">http</option>
|
||||
<option value="wss">wss (WebSocket Secure)</option>
|
||||
<option value="ws">ws (WebSocket)</option>
|
||||
</x-forms.datalist>
|
||||
|
||||
<x-forms.input wire:model="domain" label="Domain" placeholder="app.example.com" required
|
||||
helper="Enter the domain name without protocol or port.<br><br><span class='text-helper'>Example</span><br>app.coolify.io or cloud.coolify.io/dashboard" />
|
||||
|
||||
<x-forms.input wire:model="port" label="Port (Optional)" placeholder="Default: 80 (http) / 443 (https)"
|
||||
helper="Leave empty to use default port. Common ports: 80 (HTTP), 443 (HTTPS), 3000, 8080" />
|
||||
|
||||
<x-forms.button @click="modalOpen=false" type="submit">
|
||||
Add Domain
|
||||
</x-forms.button>
|
||||
</form>
|
||||
@@ -0,0 +1,19 @@
|
||||
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submit'>
|
||||
<x-forms.datalist wire:model="scheme" label="Scheme" placeholder="Select or type a scheme" required
|
||||
helper="Select from common schemes or type a custom one (e.g., tcp, grpc, ftp)">
|
||||
<option value="https">https</option>
|
||||
<option value="http">http</option>
|
||||
<option value="wss">wss (WebSocket Secure)</option>
|
||||
<option value="ws">ws (WebSocket)</option>
|
||||
</x-forms.datalist>
|
||||
|
||||
<x-forms.input wire:model="domainName" label="Domain" placeholder="app.example.com" required
|
||||
helper="Enter the domain name without protocol or port.<br><br><span class='text-helper'>Example</span><br>app.coolify.io or cloud.coolify.io/dashboard" />
|
||||
|
||||
<x-forms.input wire:model="port" label="Port (Optional)" placeholder="Default: 80 (http) / 443 (https)"
|
||||
helper="Leave empty to use default port. Common ports: 80 (HTTP), 443 (HTTPS), 3000, 8080" />
|
||||
|
||||
<x-forms.button @click="modalOpen=false" type="submit">
|
||||
Update Domain
|
||||
</x-forms.button>
|
||||
</form>
|
||||
@@ -0,0 +1,120 @@
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Domains</h2>
|
||||
@can('update', $application)
|
||||
<x-modal-input buttonTitle="+ Add" title="New Domain">
|
||||
<livewire:project.application.domain-add :application="$application" />
|
||||
</x-modal-input>
|
||||
<x-forms.button wire:click="generateDomain">Generate Domain</x-forms.button>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="pb-4">Manage domains for your application.</div>
|
||||
|
||||
@if ($application->build_pack === 'dockercompose')
|
||||
<x-callout type="warning" title="Note" class="mb-4">
|
||||
This view is for simple applications only. Docker Compose applications manage domains per service in the General view.
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
@if (empty($domains))
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<svg class="w-16 h-16 mb-4 text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<h3 class="mb-2">No domains configured</h3>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Add a domain to make your application accessible via a custom URL.
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col gap-2 pb-6">
|
||||
@foreach ($domains as $index => $domain)
|
||||
<div class="flex items-center gap-4 p-2 min-h-[4rem] rounded-sm border border-neutral-200 dark:border-coolgray-300 bg-white dark:bg-coolgray-100 shadow-sm">
|
||||
{{-- Check Icon --}}
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-success" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- Domain Name --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-mono text-base break-all text-black dark:text-white">{{ $domain }}</div>
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">Valid Configuration</div>
|
||||
</div>
|
||||
|
||||
{{-- Action Buttons --}}
|
||||
<div class="flex gap-2">
|
||||
@can('view', $application)
|
||||
<x-forms.button wire:click="checkDNS('{{ $domain }}')" title="Check DNS">
|
||||
Refresh
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
@can('update', $application)
|
||||
<x-modal-input buttonTitle="Edit" title="Edit Domain">
|
||||
<livewire:project.application.domain-edit :application="$application" :index="$index"
|
||||
:domain="$domain" :key="'edit-' . $index" />
|
||||
</x-modal-input>
|
||||
<x-modal-confirmation title="Remove Domain?"
|
||||
message="Are you sure you want to remove this domain: {{ $domain }}?"
|
||||
buttonTitle="Remove" submitAction="removeDomain({{ $index }})"
|
||||
:confirmWithText="false" :confirmWithPassword="false" isErrorButton>
|
||||
<x-slot:button>
|
||||
<x-forms.button isError title="Remove domain">
|
||||
Remove
|
||||
</x-forms.button>
|
||||
</x-slot:button>
|
||||
</x-modal-confirmation>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h3>Redirect Settings</h3>
|
||||
<div class="pb-2 text-sm">Configure how www and non-www domains should be handled.</div>
|
||||
|
||||
@if ($application->settings->is_container_label_readonly_enabled == false)
|
||||
@if ($application->redirect === 'both')
|
||||
<x-forms.input label="Direction" value="Allow www & non-www." readonly
|
||||
helper="Readonly labels are disabled. You can set the direction in the labels section." />
|
||||
@elseif ($application->redirect === 'www')
|
||||
<x-forms.input label="Direction" value="Redirect to www." readonly
|
||||
helper="Readonly labels are disabled. You can set the direction in the labels section." />
|
||||
@elseif ($application->redirect === 'non-www')
|
||||
<x-forms.input label="Direction" value="Redirect to non-www." readonly
|
||||
helper="Readonly labels are disabled. You can set the direction in the labels section." />
|
||||
@endif
|
||||
@else
|
||||
<div class="flex items-end gap-2">
|
||||
<x-forms.select wire:model="redirect" label="Direction" required
|
||||
helper="You must need to add www and non-www as an A DNS record. Make sure the www domain is added under your domains list.">
|
||||
<option value="both">Allow www & non-www.</option>
|
||||
<option value="www">Redirect to www.</option>
|
||||
<option value="non-www">Redirect to non-www.</option>
|
||||
</x-forms.select>
|
||||
@can('update', $application)
|
||||
<x-modal-confirmation title="Confirm Redirection Setting?" buttonTitle="Set Direction"
|
||||
submitAction="setRedirect" :actions="['All traffic will be redirected to the selected direction.']"
|
||||
confirmationText="{{ $application->fqdn . '/' }}"
|
||||
confirmationLabel="Please confirm the execution of the action by entering the Application URL below"
|
||||
shortConfirmationLabel="Application URL" :confirmWithPassword="false"
|
||||
step2ButtonText="Set Direction">
|
||||
<x-slot:customButton>
|
||||
<div class="w-[7.2rem]">Set Direction</div>
|
||||
</x-slot:customButton>
|
||||
</x-modal-confirmation>
|
||||
@endcan
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Domain Conflict Modal --}}
|
||||
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
|
||||
confirmAction="confirmDomainUsage" />
|
||||
</div>
|
||||
@@ -100,58 +100,41 @@
|
||||
@endif
|
||||
</div>
|
||||
@if ($buildPack !== 'dockercompose')
|
||||
<div class="flex items-end gap-2">
|
||||
@if ($application->settings->is_container_label_readonly_enabled == false)
|
||||
<x-forms.input placeholder="https://coolify.io" wire:model="fqdn" label="Domains" readonly
|
||||
helper="Readonly labels are disabled. You can set the domains in the labels section."
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@else
|
||||
<x-forms.input placeholder="https://coolify.io" wire:model="fqdn" label="Domains"
|
||||
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@can('update', $application)
|
||||
<x-forms.button wire:click="getWildcardDomain">Generate Domain
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
@endif
|
||||
<div class="flex items-center gap-2 pt-4">
|
||||
<h3>Domains</h3>
|
||||
<a class="text-sm underline"
|
||||
href="{{ route('project.application.domains', ['project_uuid' => request()->route('project_uuid'), 'environment_uuid' => request()->route('environment_uuid'), 'application_uuid' => $application->uuid]) }}">
|
||||
Manage domains →
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
@if ($application->settings->is_container_label_readonly_enabled == false)
|
||||
@if ($application->redirect === 'both')
|
||||
<x-forms.input label="Direction" value="Allow www & non-www." readonly
|
||||
helper="Readonly labels are disabled. You can set the direction in the labels section."
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@elseif ($application->redirect === 'www')
|
||||
<x-forms.input label="Direction" value="Redirect to www." readonly
|
||||
helper="Readonly labels are disabled. You can set the direction in the labels section."
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@elseif ($application->redirect === 'non-www')
|
||||
<x-forms.input label="Direction" value="Redirect to non-www." readonly
|
||||
helper="Readonly labels are disabled. You can set the direction in the labels section."
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@endif
|
||||
<div class="pb-4">
|
||||
@if (!empty($application->fqdn))
|
||||
@php
|
||||
$allDomains = $application->fqdns;
|
||||
$displayLimit = 3;
|
||||
$displayDomains = array_slice($allDomains, 0, $displayLimit);
|
||||
$remainingCount = count($allDomains) - $displayLimit;
|
||||
@endphp
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($displayDomains as $domain)
|
||||
@php
|
||||
$host = parse_url($domain, PHP_URL_HOST) ?? $domain;
|
||||
@endphp
|
||||
<span class="px-2 py-1 text-xs font-mono rounded bg-neutral-100 dark:bg-coolgray-300 text-neutral-700 dark:text-neutral-300 truncate max-w-[200px]" title="{{ $domain }}">
|
||||
{{ $host }}
|
||||
</span>
|
||||
@endforeach
|
||||
@if ($remainingCount > 0)
|
||||
<a href="{{ route('project.application.domains', ['project_uuid' => request()->route('project_uuid'), 'environment_uuid' => request()->route('environment_uuid'), 'application_uuid' => $application->uuid]) }}"
|
||||
class="px-2 py-1 text-xs rounded bg-neutral-200 dark:bg-coolgray-200 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200">
|
||||
+{{ $remainingCount }} more
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<x-forms.select label="Direction" id="redirect" required
|
||||
helper="You must need to add www and non-www as an A DNS record. Make sure the www domain is added under Domains."
|
||||
x-bind:disabled="!canUpdate">
|
||||
<option value="both">Allow www & non-www.</option>
|
||||
<option value="www">Redirect to www.</option>
|
||||
<option value="non-www">Redirect to non-www.</option>
|
||||
</x-forms.select>
|
||||
@if ($application->settings->is_container_label_readonly_enabled)
|
||||
@can('update', $application)
|
||||
<x-modal-confirmation title="Confirm Redirection Setting?" buttonTitle="Set Direction"
|
||||
submitAction="setRedirect" :actions="['All traffic will be redirected to the selected direction.']"
|
||||
confirmationText="{{ $application->fqdn . '/' }}"
|
||||
confirmationLabel="Please confirm the execution of the action by entering the Application URL below"
|
||||
shortConfirmationLabel="Application URL" :confirmWithPassword="false"
|
||||
step2ButtonText="Set Direction">
|
||||
<x-slot:customButton>
|
||||
<div class="w-[7.2rem]">Set Direction</div>
|
||||
</x-slot:customButton>
|
||||
</x-modal-confirmation>
|
||||
@endcan
|
||||
@endif
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No domains configured. Add domains in the Domains view.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -193,6 +193,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/', ApplicationConfiguration::class)->name('project.application.configuration');
|
||||
Route::get('/swarm', ApplicationConfiguration::class)->name('project.application.swarm');
|
||||
Route::get('/advanced', ApplicationConfiguration::class)->name('project.application.advanced');
|
||||
Route::get('/domains', ApplicationConfiguration::class)->name('project.application.domains');
|
||||
Route::get('/environment-variables', ApplicationConfiguration::class)->name('project.application.environment-variables');
|
||||
Route::get('/persistent-storage', ApplicationConfiguration::class)->name('project.application.persistent-storage');
|
||||
Route::get('/source', ApplicationConfiguration::class)->name('project.application.source');
|
||||
|
||||
Reference in new issue
Block a user