Merge remote-tracking branch 'origin/next' into celld-one-click-service

This commit is contained in:
Andras Bacsai
2026-08-07 18:01:47 +02:00
100 changed files with 1349 additions and 493 deletions
+1 -4
View File
@@ -23,13 +23,10 @@ class StartSentinel
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
$token = $server->settings->ensureValidSentinelToken();
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$endpoint = $server->settings->ensureSentinelUrl();
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version;
if (! $endpoint) {
throw new \RuntimeException('You should set FQDN in Instance Settings.');
}
$environments = [
'TOKEN' => $token,
'DEBUG' => $debug ? 'true' : 'false',
@@ -77,7 +77,6 @@ class EditCompose extends Component
{
try {
$this->authorize('update', $this->service);
$this->dispatch('info', 'Saving new docker compose...');
$this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
@@ -140,6 +140,7 @@ class StackForm extends Component
{
$this->dockerComposeRaw = $raw;
$this->submit(notify: true);
$this->dispatch('compose-save-finished');
}
public function instantSave()
+2 -1
View File
@@ -114,9 +114,10 @@ class Sentinel extends Component
return;
}
$this->isSentinelEnabled = true;
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
StartSentinel::run($this->server, true, null, $customImage);
$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;
$this->isSentinelEnabled = true;
} else {
$this->isSentinelEnabled = false;
$this->isMetricsEnabled = false;
@@ -39,6 +39,8 @@ class ValidateAndInstall extends Component
public bool $ask = false;
public bool $isInstalling = false;
protected $listeners = [
'init',
'validateConnection',
@@ -51,6 +53,7 @@ class ValidateAndInstall extends Component
public function init(int $data = 0)
{
$this->isInstalling = false;
$this->uptime = null;
$this->supported_os_type = null;
$this->prerequisites_installed = null;
@@ -172,6 +175,7 @@ class ValidateAndInstall extends Component
if ($this->number_of_tries <= $this->max_tries) {
$this->installationStep = 'Prerequisites';
$activity = $this->server->installPrerequisites();
$this->isInstalling = true;
$this->number_of_tries++;
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
}
@@ -208,6 +212,7 @@ class ValidateAndInstall extends Component
if ($this->number_of_tries <= $this->max_tries) {
$this->installationStep = 'Docker';
$activity = $this->server->installDocker();
$this->isInstalling = true;
$this->number_of_tries++;
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
}
+41 -1
View File
@@ -219,7 +219,22 @@ class ServerSetting extends Model
return $token;
}
public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false)
public function ensureSentinelUrl(): string
{
$url = $this->sentinel_custom_url;
if (blank($url)) {
$url = $this->generateSentinelUrl(ignoreEvent: true);
}
if (blank($url)) {
throw new \RuntimeException('Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.');
}
return $url;
}
public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false): ?string
{
$domain = null;
$settings = InstanceSettings::get();
@@ -231,6 +246,8 @@ class ServerSetting extends Model
$domain = 'http://'.$settings->public_ipv4.':8000';
} elseif ($settings->public_ipv6) {
$domain = 'http://'.$settings->public_ipv6.':8000';
} else {
$domain = $this->sentinelUrlFromCurrentRequest();
}
$this->sentinel_custom_url = $domain;
if ($save) {
@@ -244,6 +261,29 @@ class ServerSetting extends Model
return $domain;
}
private function sentinelUrlFromCurrentRequest(): ?string
{
if (! app()->bound('request')) {
return null;
}
$request = request();
$host = strtolower($request->getHost());
if (
$host === 'localhost' ||
str_ends_with($host, '.localhost') ||
$host === '::1' ||
$host === '::' ||
$host === '0.0.0.0' ||
str_starts_with($host, '127.')
) {
return null;
}
return $request->getSchemeAndHttpHost();
}
public function server()
{
return $this->belongsTo(Server::class);
+1 -1
View File
@@ -253,7 +253,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
} catch (Exception) {
$timestamp->setTimezone('UTC');
}
data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s.u'));
data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s'));
return $i;
})
+9 -5
View File
@@ -872,7 +872,7 @@ html[data-theme="custom"] {
--theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, white);
--theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground));
--theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, #52525b);
--theme-placeholder-color: color-mix(in srgb, white 82%, var(--theme-base-color));
--theme-placeholder-color: color-mix(in srgb, white 20%, var(--theme-base-color));
--color-accent: var(--theme-bright-color);
--color-coollabs: var(--theme-bright-color);
--color-coollabs-100: color-mix(in oklab, var(--theme-bright-color) 88%, white);
@@ -966,7 +966,7 @@ html[data-theme="custom"] [class~="dark:bg-white/[0.025]"] {
html[data-theme="custom"] input::placeholder,
html[data-theme="custom"] textarea::placeholder {
color: var(--theme-placeholder-color) !important;
opacity: 1;
opacity: 0.7;
}
html[data-theme="custom"] input:read-only,
@@ -1430,8 +1430,10 @@ html[data-theme="custom"] textarea:disabled {
/* Inputs & selects: recessed fill + line border (surface hierarchy) */
.application-settings-workspace .input,
.application-settings-workspace .select,
.application-settings-workspace .listbox-trigger,
.application-settings-form .input,
.application-settings-form .select {
.application-settings-form .select,
.application-settings-form .listbox-trigger {
height: 2rem;
border-radius: 8px;
border-color: var(--coollabs-line);
@@ -1741,7 +1743,7 @@ html[data-theme="custom"] textarea:disabled {
gap: 0.5rem;
width: 100%;
min-width: 0;
height: 2rem;
height: 2.25rem;
padding: 0 0.625rem 0 0.75rem;
overflow: hidden;
border-radius: 8px;
@@ -2441,7 +2443,9 @@ input[type="search"]::-webkit-search-results-decoration {
}
.backup-executions-table-grid {
grid-template-columns: 7.5rem minmax(9rem, 1fr) 8rem 6rem 5rem minmax(9rem, 1fr) minmax(9rem, auto);
grid-template-columns: 6.5rem minmax(7rem, 1fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;
gap: 0.75rem;
min-width: 49rem;
}
.volume-backup-executions-grid {
+40 -1
View File
@@ -8,7 +8,14 @@ import {
} from './terminal-session-timer.js';
import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV;
const terminalDebugParameter = new URLSearchParams(window.location.search).get('terminal-debug');
if (terminalDebugParameter === '1' || terminalDebugParameter === '0') {
localStorage.setItem('coolify-terminal-debug', terminalDebugParameter);
}
const terminalDebugEnabled = import.meta.env.DEV
|| localStorage.getItem('coolify-terminal-debug') === '1';
const baseApplicationTerminalTheme = {
black: '#675f70',
@@ -393,9 +400,15 @@ export function initializeTerminalComponent() {
setTerminalTheme(themeName) {
if (!applicationTerminalThemes[themeName]) {
logTerminal('warn', '[Terminal Theme] Unknown theme', {
requestedTheme: themeName,
availableThemes: Object.keys(applicationTerminalThemes),
});
return;
}
logTerminal('log', '[Terminal Theme] Applying theme', this.terminalThemeDebugSnapshot(themeName));
if (themeName === 'system') {
applicationTerminalThemes.system = createSystemTerminalTheme();
}
@@ -417,10 +430,36 @@ export function initializeTerminalComponent() {
this.term.options.cursorBlink = cursorBlink;
this.term.refresh(0, Math.max(0, this.term.rows - 1));
this.term.focus();
logTerminal('log', '[Terminal Theme] Theme applied', this.terminalThemeDebugSnapshot(themeName));
});
}
},
terminalThemeDebugSnapshot(themeName) {
const shell = this.$el.closest('.application-console-shell');
const viewport = this.term?.element?.querySelector('.xterm-viewport');
const screen = this.term?.element?.querySelector('.xterm-screen');
return {
requestedTheme: themeName,
selectedTheme: this.selectedTheme,
terminalExists: Boolean(this.term),
terminalOpened: Boolean(this.term?.element),
terminalActive: this.terminalActive,
connectionState: this.connectionState,
shellTheme: shell?.dataset.consoleTheme,
shellBackground: shell ? getComputedStyle(shell).background : null,
shellThemeBackground: shell ? getComputedStyle(shell).getPropertyValue('--console-theme-background') : null,
shellThemeOpacity: shell ? getComputedStyle(shell).getPropertyValue('--console-theme-opacity') : null,
shellPseudoBackground: shell ? getComputedStyle(shell, '::before').background : null,
viewportBackground: viewport ? getComputedStyle(viewport).background : null,
screenBackground: screen ? getComputedStyle(screen).background : null,
xtermBackground: this.term?.options.theme?.background,
xtermForeground: this.term?.options.theme?.foreground,
xtermCursor: this.term?.options.theme?.cursor,
};
},
resetTerminal() {
if (this.term) {
this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
@@ -12,10 +12,10 @@
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'],
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()],
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'navigate' => false, 'visible' => auth()->user()?->can('update', $database)],
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
['label' => 'Runtime', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'visible' => auth()->user()?->can('update', $database)],
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'],
@@ -32,7 +32,7 @@
]);
$menuGroups = [
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Servers', 'Import Backup'],
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers'],
'Automation' => ['Webhooks', 'Healthcheck'],
'Logs' => ['Runtime'],
'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
@@ -41,6 +41,19 @@
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
$pageSections = $database->type() === 'standalone-postgresql'
? [
['id' => 'database-details-section', 'label' => 'Database details'],
['id' => 'credentials-section', 'label' => 'Credentials'],
['id' => 'initialization-section', 'label' => 'Initialization'],
['id' => 'runtime-network-section', 'label' => 'Runtime and network'],
['id' => 'public-access-section', 'label' => 'Public access'],
['id' => 'configuration-section', 'label' => 'Configuration'],
['id' => 'log-delivery-section', 'label' => 'Log delivery'],
['id' => 'initialization-scripts-section', 'label' => 'Initialization scripts'],
]
: [];
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
@@ -58,6 +71,24 @@
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@if ($menuItem['active'] && $menuItem['route'] === 'project.database.configuration' && $pageSections !== [])
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{
activeSection: '',
scrollToSection(id) {
this.activeSection = id;
window.scrollToSettingsSection?.(id);
},
}">
@foreach ($pageSections as $section)
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="scrollToSection('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
@endforeach
@endforeach
</nav>
@@ -0,0 +1,22 @@
@props([
'title' => 'Advanced settings',
'contentClass' => '',
])
<div x-data="{ open: false }" {{ $attributes->class(['flex flex-col gap-4']) }}>
<button type="button" x-on:click="open = !open"
class="flex items-center gap-2 text-left text-sm font-medium hover:underline" :aria-expanded="open">
<svg class="size-4 transition-transform" x-bind:class="open && 'rotate-90'" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 1 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02Z"
clip-rule="evenodd" />
</svg>
{{ $title }}
</button>
<div x-show="open" x-cloak
class="rounded-lg border border-neutral-200 p-4 dark:border-coolgray-400 {{ $contentClass }}">
{{ $slot }}
</div>
</div>
@@ -1,5 +1,6 @@
@props([
'id' => null,
'htmlId' => null,
'label' => null,
'helper' => null,
'required' => false,
@@ -11,8 +12,13 @@
'wire' => true, // false = purely client-side value (no Livewire binding)
'value' => null, // initial value when wire=false
'disabled' => false,
'tooltip' => true,
])
@php
$triggerId = ($htmlId ?? $id).'-trigger';
@endphp
<div class="w-full min-w-0">
@if ($label)
{{--
@@ -22,7 +28,7 @@
--}}
{{-- Fixed h-4 matches the helper icon so side-by-side fields align with or without a helper. --}}
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-trigger" class="mb-0! flex items-center gap-1.5 leading-4">
<label for="{{ $triggerId }}" class="mb-0! flex items-center gap-1.5 leading-4">
{{ $label }}
@if ($required)
<x-highlighted text="*" />
@@ -51,9 +57,9 @@
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@click.outside="open = false" @keydown.escape="open = false">
<button id="{{ $id }}-trigger" type="button" class="listbox-trigger" @click="open = !open"
<button id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="open = !open"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" :title="current">
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
<span class="listbox-trigger-label" x-text="current"></span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
@@ -12,6 +12,7 @@
// Optional Livewire bool property to entangle open state (survives Livewire re-renders).
'wireOpen' => null,
'contentClicks' => true,
'isLarge' => false,
])
@php
@@ -54,10 +55,19 @@
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 max-h-[calc(100dvh-2rem)] w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl"
@class([
'application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden',
'lg:w-[95vw]! lg:max-w-7xl!' => $isLarge,
'lg:w-auto lg:min-w-2xl lg:max-w-4xl' => ! $isLarge,
])
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">{{ $title }}</h3>
@isset($headerActions)
<div class="flex shrink-0 items-center gap-2">
{{ $headerActions }}
</div>
@endisset
<button type="button" @click="modalOpen=false"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-4" />
+1 -1
View File
@@ -89,7 +89,7 @@
</li>
@can('canAccessTerminal')
<li>
<a title="Terminal" {{ wireNavigate() }}
<a title="Terminal"
class="{{ request()->is('terminal*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('terminal') }}">
<x-reicon name="browser-terminal" class="menu-item-icon" />
@@ -1,5 +1,6 @@
@props([
'closeWithX' => false,
'open' => false,
'size' => 'lg',
])
@@ -15,7 +16,7 @@
ghost layout box above layer-2 tabs. Trigger buttons in the slot still flow
into the parent as if unwrapped. --}}
<div x-data="{
processDialogOpen: false
processDialogOpen: @js($open)
}"
x-init="$watch('processDialogOpen', value => {
if (!value) {
@@ -76,7 +76,7 @@
[
'label' => 'Resources',
'route' => 'server.resources',
'active' => request()->routeIs('server.resources'),
'active' => $activeMenu === 'resources',
'icon' => 'projects',
'group' => 'Platform',
],
@@ -1,7 +1,7 @@
@php use App\Actions\CoolifyTask\RunRemoteProcess; @endphp
<div @class([
'h-full flex flex-col overflow-hidden' => $fullHeight,
'h-full overflow-hidden' => !$fullHeight,
'overflow-hidden' => !$fullHeight,
])>
@if ($activity)
@if (isset($header))
@@ -97,8 +97,12 @@
<x-slot:actions>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 w-full">
<button
class="group relative min-h-36 rounded-[10px] border border-neutral-200 bg-white p-4 text-left transition-colors hover:border-coollabs/35 hover:bg-coollabs/[0.03] dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]"
class="group relative cursor-pointer min-h-36 rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
wire:target="setServerType('localhost')" wire:click="setServerType('localhost')">
<span role="button" tabindex="0" aria-label="About this machine"
data-tooltip="The machine running Coolify. Not recommended for production workloads due to resource contention."
@click.stop @keydown.enter.stop @keydown.space.prevent.stop
class="absolute top-3 right-3 flex size-6 items-center justify-center rounded-full border border-neutral-200 text-[11px] font-semibold text-neutral-500 hover:border-coollabs/35 hover:text-coollabs dark:border-white/[0.1] dark:text-fg-dim dark:hover:border-warning/30 dark:hover:text-warning">i</span>
<div class="flex flex-col gap-4 text-left">
<svg class="size-10" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
@@ -117,8 +121,12 @@
<button
class="group relative min-h-36 rounded-[10px] border border-neutral-200 bg-white p-4 text-left transition-colors hover:border-coollabs/35 hover:bg-coollabs/[0.03] dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]"
class="group relative cursor-pointer min-h-36 rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
wire:target="setServerType('remote')" wire:click="setServerType('remote')">
<span role="button" tabindex="0" aria-label="About remote servers"
data-tooltip="Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure."
@click.stop @keydown.enter.stop @keydown.space.prevent.stop
class="absolute top-3 right-3 flex size-6 items-center justify-center rounded-full border border-neutral-200 text-[11px] font-semibold text-neutral-500 hover:border-coollabs/35 hover:text-coollabs dark:border-white/[0.1] dark:text-fg-dim dark:hover:border-warning/30 dark:hover:text-warning">i</span>
<div class="flex flex-col gap-4 text-left">
<svg class="size-10" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
@@ -138,7 +146,7 @@
<x-modal-input title="Connect a Hetzner Server" isFullWidth>
<x-slot:content>
<div
class="group relative flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left transition-colors hover:border-coollabs/35 hover:bg-coollabs/[0.03] dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]">
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<div class="flex h-full flex-col gap-4 text-left">
<svg class="size-10 shrink-0" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
@@ -160,7 +168,7 @@
<x-modal-input title="Connect a Vultr Server" isFullWidth>
<x-slot:content>
<div
class="group relative flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left transition-colors hover:border-coollabs/35 hover:bg-coollabs/[0.03] dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]">
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<div class="flex h-full flex-col gap-4 text-left">
<svg class="size-10 shrink-0" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
@@ -179,6 +187,23 @@
</x-slot:content>
<livewire:server.new.by-vultr :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
<x-modal-input title="Connect a DigitalOcean Server" isFullWidth>
<x-slot:content>
<div
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<div class="flex h-full flex-col gap-4 text-left">
<x-digital-ocean-icon class="size-10 shrink-0" />
<div class="min-h-0 flex-1">
<h3 class="mb-1 text-[14px] font-semibold">DigitalOcean</h3>
<p class="text-sm dark:text-neutral-400">
Deploy servers directly from your DigitalOcean account.
</p>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
@endif
@endcan
</div>
@@ -229,20 +254,6 @@
</div>
@endif
</x-slot:actions>
<x-slot:explanation>
<p>
<x-highlighted text="Servers" /> host your applications, databases, and services (collectively
called resources). All CPU-intensive operations run on the target server.
</p>
<p>
<x-highlighted text="Localhost:" /> The machine running Coolify. Not recommended for production
workloads due to resource contention.
</p>
<p>
<x-highlighted text="Remote Server:" /> Any SSH-accessible server: cloud providers (AWS, Hetzner,
DigitalOcean), bare metal, or self-hosted infrastructure.
</p>
</x-slot:explanation>
</x-boarding-step>
@elseif ($currentState === 'private-key')
<x-boarding-progress :currentStep="2" />
@@ -268,7 +279,7 @@
class="flex flex-col gap-3 sm:flex-row sm:items-end">
<div class="min-w-0 flex-1">
<x-forms.listbox id="selectedExistingPrivateKey"
label="Existing SSH key" :options="$privateKeyOptions" />
label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false" />
</div>
<x-forms.button type="submit">Use selected key</x-forms.button>
</form>
@@ -288,7 +299,7 @@
@endif
<div class="grid w-full grid-cols-1 gap-3 lg:grid-cols-2">
<button type="button"
class="group flex h-full min-h-28 items-start gap-3 rounded-[10px] border border-neutral-200 bg-white p-4 text-left transition-colors hover:border-coollabs/35 hover:bg-coollabs/[0.03] dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]"
class="group flex h-full min-h-28 items-start gap-3 rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
wire:target="setPrivateKey('own')" wire:click="setPrivateKey('own')">
<span
class="flex size-9 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">
@@ -302,7 +313,7 @@
</span>
</button>
<button type="button"
class="group flex h-full min-h-28 items-start gap-3 rounded-[10px] border border-neutral-200 bg-white p-4 text-left transition-colors hover:border-coollabs/35 hover:bg-coollabs/[0.03] dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]"
class="group flex h-full min-h-28 items-start gap-3 rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
wire:target="setPrivateKey('create')" wire:click="setPrivateKey('create')">
<span
class="flex size-9 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">
@@ -410,57 +421,24 @@
<x-forms.input placeholder="Optional: Note what this server hosts" label="Description"
id="remoteServerDescription" wire:model="remoteServerDescription" />
<div x-data="{ showAdvanced: false }" class="flex flex-col gap-4">
<button @click="showAdvanced = !showAdvanced" type="button"
class="flex items-center gap-2 text-left text-sm font-medium hover:underline">
<svg x-show="!showAdvanced" class="size-4" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd" />
</svg>
<svg x-show="showAdvanced" class="size-4" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd" />
</svg>
Advanced Connection Settings
</button>
<div x-show="showAdvanced" x-cloak
class="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<x-forms.input placeholder="Default: 22" label="SSH Port" type="number"
id="remoteServerPort" wire:model="remoteServerPort" />
<div>
<x-forms.input placeholder="Default: root" label="SSH User" id="remoteServerUser"
wire:model="remoteServerUser" />
<p class="mt-1 text-xs dark:text-white text-black">
Non-root user support is experimental.
<a class="font-bold underline hover:text-coollabs" target="_blank"
href="https://coolify.io/docs/knowledge-base/server/non-root-user">Learn
more</a>
</p>
</div>
<x-forms.collapsible title="Advanced Connection Settings"
content-class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<x-forms.input placeholder="Default: 22" label="SSH Port" type="number"
id="remoteServerPort" wire:model="remoteServerPort" />
<div>
<x-forms.input placeholder="Default: root" label="SSH User" id="remoteServerUser"
wire:model="remoteServerUser" />
<p class="mt-1 text-xs text-black dark:text-white">
Non-root user support is experimental.
<a class="font-bold underline hover:text-coollabs" target="_blank"
href="https://coolify.io/docs/knowledge-base/server/non-root-user">Learn
more</a>
</p>
</div>
</div>
</x-forms.collapsible>
<x-forms.button type="submit" class="w-full lg:w-auto">Validate Connection</x-forms.button>
</form>
</x-slot:actions>
<x-slot:explanation>
<p>
<x-highlighted text="Connection Requirements:" /> Server must be accessible via SSH on the
specified port (default 22).
</p>
<p>
<x-highlighted text="Hostname Resolution:" /> Use IP addresses for direct connections or ensure
DNS resolution is configured.
</p>
<p>
<x-highlighted text="User Permissions:" /> Root or sudo-enabled users recommended for full
Docker
management capabilities.
</p>
</x-slot:explanation>
</x-boarding-step>
@elseif ($currentState === 'validate-server')
<x-boarding-progress :currentStep="2" />
@@ -507,16 +485,16 @@
</section>
@endif
<x-slide-over closeWithX fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Server validation</x-slot:title>
<x-slot:content>
<livewire:server.validate-and-install :server="$this->createdServer" />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true" class="w-full justify-center"
<x-forms.button @click="processDialogOpen = true" class="w-full justify-center"
wire:click.prevent="installServer" isHighlighted>
Start validation
</x-forms.button>
</x-slide-over>
</x-process-dialog>
</div>
</x-slot:actions>
<x-slot:explanation>
@@ -582,22 +560,6 @@
@endif
</div>
</x-slot:actions>
<x-slot:explanation>
<p>
<x-highlighted text="Project Organization:" /> Group related resources (apps, databases,
services)
into logical projects.
</p>
<p>
<x-highlighted text="Environments:" /> Each project includes a production environment by
default.
Add staging, development, or custom environments as needed.
</p>
<p>
<x-highlighted text="Team Access:" /> Projects inherit team permissions and can be managed
collaboratively.
</p>
</x-slot:explanation>
</x-boarding-step>
@elseif ($currentState === 'create-resource')
<x-boarding-progress :currentStep="3" />
@@ -659,7 +621,7 @@
</div>
@if ($currentState !== 'welcome' && $currentState !== 'create-resource')
<div class="mt-6 flex w-full max-w-3xl flex-col items-center gap-3">
<div class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3">
<div
class="inline-flex flex-wrap items-center justify-center gap-0.5 rounded-lg border border-neutral-200 bg-neutral-50 p-0.5 dark:border-white/[0.08] dark:bg-white/[0.025]">
<button type="button" wire:click="skipBoarding"
@@ -134,7 +134,8 @@
});
},
navigateResults(direction) {
const results = document.querySelectorAll('.search-result-item');
const results = Array.from(this.$el.querySelectorAll('.search-result-item'))
.filter(item => item.offsetParent !== null);
if (results.length === 0) return;
if (direction === 'down') {
@@ -157,35 +157,32 @@
helper="Git repository (based on the base directory settings) will be copied to the deployment directory."
x-bind:disabled="shouldDisable()" />
</div>
<div class="pt-4">The following commands are for advanced use cases.
Only
modify them if you
know what are
you doing.</div>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose build" id="dockerComposeCustomBuildCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose build</span>"
label="Custom build command" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose up -d" id="dockerComposeCustomStartCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose up -d</span>"
label="Custom start command" />
<div class="grid gap-4 pt-4">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose build" id="dockerComposeCustomBuildCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose build</span>"
label="Custom build command" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose up -d" id="dockerComposeCustomStartCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose up -d</span>"
label="Custom start command" />
</div>
@if ($this->dockerComposeCustomBuildCommand)
<div wire:key="docker-compose-build-preview">
<x-forms.input readonly value="{{ $this->dockerComposeBuildCommandPreview }}"
label="Final build command (preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->dockerComposeCustomStartCommand)
<div wire:key="docker-compose-start-preview">
<x-forms.input readonly value="{{ $this->dockerComposeStartCommandPreview }}"
label="Final start command (preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
</div>
@if ($this->dockerComposeCustomBuildCommand)
<div wire:key="docker-compose-build-preview">
<x-forms.input readonly value="{{ $this->dockerComposeBuildCommandPreview }}"
label="Final build command (preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->dockerComposeCustomStartCommand)
<div wire:key="docker-compose-start-preview">
<x-forms.input readonly value="{{ $this->dockerComposeStartCommandPreview }}"
label="Final start command (preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
<div class="pt-4">
<x-forms.textarea
@@ -306,8 +303,8 @@
</div>
@endif
@if ($buildPack === 'dockercompose')
<div x-data="{ showRaw: true }">
<div class="flex items-center gap-4">
<div x-data="{ showRaw: true }" class="mt-5">
<div class="flex items-center justify-between gap-4">
<h3>Docker Compose</h3>
<x-forms.button x-show="{{ $application->settings->is_raw_compose_deployment_enabled ? 'false' : 'true' }}"
@click.prevent="showRaw = !showRaw"
@@ -42,7 +42,7 @@
<button type="button" class="button w-full justify-between" @click="open = !open"
:aria-expanded="open" aria-haspopup="menu">
<span class="inline-flex items-center gap-2">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
<x-reicon name="play-circle" class="size-3.5 text-warning" />
Actions
</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
@@ -202,7 +202,7 @@
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
aria-haspopup="menu">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
<x-reicon name="play-circle" class="size-3.5 text-warning" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
@@ -31,8 +31,8 @@
|| $backup->database_type === 'App\Models\StandaloneMariadb')
<div class="grid w-full gap-4">
<x-forms.listbox id="dumpAll" label="Database selection" onChange="instantSave" :options="[
['value' => true, 'label' => 'Back up all databases'],
['value' => false, 'label' => 'Choose databases'],
['value' => true, 'label' => 'All databases'],
['value' => false, 'label' => 'Specific databases'],
]" />
@if (! $backup->dump_all)
<div class="w-full" x-data="{
@@ -25,8 +25,9 @@
<div @if (! $skip) wire:poll.5000ms="refreshBackupExecutions" @endif
class="application-settings-section-body p-0!">
<div class="data-table">
<div class="data-table-header backup-executions-table-grid">
<div class="data-table deployment-table-scroll">
<div
class="data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]">
<span>Status</span>
<span>Database</span>
<span>Finished</span>
@@ -67,7 +68,7 @@
@endphp
<div wire:key="{{ data_get($execution, 'id') }}"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]">
<div class="data-table-row backup-executions-table-grid">
<div class="data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5">
<div class="flex items-center gap-2">
<x-status-badge :status="$executionStatusLabel"
:type="$executionStatusType" />
@@ -110,19 +111,24 @@
</div>
<div class="flex items-center justify-end gap-1">
@if ($executionStatus === 'success')
<button type="button" class="button"
x-on:click="download_file('{{ data_get($execution, 'id') }}')">
Download
<button type="button" class="icon-button shrink-0"
x-on:click="download_file('{{ data_get($execution, 'id') }}')"
title="Download backup" aria-label="Download backup">
<x-reicon name="upload" class="size-3.5 rotate-180" />
</button>
@endif
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
submitAction="deleteBackup({{ data_get($execution, 'id') }})"
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
confirmationText="{{ data_get($execution, 'filename') }}"
confirmationLabel="Enter the backup filename to confirm."
shortConfirmationLabel="Backup Filename">
confirmationLabel="Enter the backup filename to confirm."
shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<x-forms.button isError>Delete</x-forms.button>
<button type="button"
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete backup" aria-label="Delete backup">
<x-reicon name="trash" class="size-3.5" />
</button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
@@ -135,18 +141,22 @@
@endif
</div>
@empty
<x-empty size="sm" title="No backup executions"
description="Execution history appears here after the schedule runs."
icon-name="browser-terminal" />
<div class="p-4">
<x-empty size="sm" title="No backup executions"
description="Execution history appears here after the schedule runs."
icon-name="browser-terminal" />
</div>
@endforelse
</div>
@if ($executions_count > 0)
<div
class="flex items-center justify-between border-t border-neutral-200 px-4 py-3 text-sm text-neutral-500 dark:border-white/[0.06] dark:text-fg-dim">
<span>{{ $executions_count }} execution{{ $executions_count === 1 ? '' : 's' }}</span>
<div class="flex items-center gap-2">
<span>Page {{ $currentPage }} of {{ ceil($executions_count / $defaultTake) }}</span>
class="flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<span>
{{ $skip + 1 }}-{{ min($skip + $defaultTake, $executions_count) }} of
{{ $executions_count }}
</span>
<div class="flex items-center gap-1">
<button type="button" class="icon-button" @disabled(! $showPrev)
wire:click="previousPage('{{ $defaultTake }}')" aria-label="Previous page">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
@@ -61,26 +61,29 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Public access"
<x-application.settings-section title="Public access" class="relative"
description="Expose this database through the managed TCP proxy.">
<x-slot:actions>
@if ($isPublic)
<x-slide-over fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
</x-slide-over>
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
</x-process-dialog>
@endif
</x-slot:actions>
<x-table.loading target="instantSave" text="Updating public access..." />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => 'Public through TCP proxy'],
]" />
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
</div>
<x-forms.input type="number" placeholder="8123" disabled="{{ $isPublic }}" id="publicPort"
label="Public port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
@@ -62,26 +62,29 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Public access"
<x-application.settings-section title="Public access" class="relative"
description="Expose this database through the managed TCP proxy.">
<x-slot:actions>
@if ($isPublic)
<x-slide-over fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
</x-slide-over>
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
</x-process-dialog>
@endif
</x-slot:actions>
<x-table.loading target="instantSave" text="Updating public access..." />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => 'Public through TCP proxy'],
]" />
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
</div>
<x-forms.input type="number" placeholder="6379" disabled="{{ $isPublic }}" id="publicPort"
label="Public port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
@@ -75,7 +75,7 @@
<button type="button" class="button w-full justify-between" @click="open = !open"
:aria-expanded="open" aria-haspopup="menu">
<span class="inline-flex items-center gap-2">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
<x-reicon name="play-circle" class="size-3.5 text-warning" />
Actions
</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
@@ -142,7 +142,7 @@
<div id="database-desktop-actions" class="relative" x-data="{ open: false }"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
<x-reicon name="play-circle" class="size-3.5 text-warning" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
@@ -252,13 +252,12 @@
</x-application.settings-section>
</div>
{{-- Slide-over for activity monitor (all restore operations) --}}
<x-slide-over @databaserestore.window="slideOverOpen = true" closeWithX fullScreen>
<x-process-dialog @databaserestore.window="processDialogOpen = true" closeWithX size="xl">
<x-slot:title>Database Restore Output</x-slot:title>
<x-slot:content>
<div wire:ignore>
<div class="flex h-full min-h-0 flex-col" wire:ignore>
<livewire:activity-monitor wire:key="database-restore-{{ $resourceUuid }}" header="Logs" fullHeight />
</div>
</x-slot:content>
</x-slide-over>
</x-process-dialog>
</div>
@@ -63,26 +63,29 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Public access"
<x-application.settings-section title="Public access" class="relative"
description="Expose this database through the managed TCP proxy.">
<x-slot:actions>
@if ($isPublic)
<x-slide-over fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
</x-slide-over>
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
</x-process-dialog>
@endif
</x-slot:actions>
<x-table.loading target="instantSave" text="Updating public access..." />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => 'Public through TCP proxy'],
]" />
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
</div>
<x-forms.input type="number" placeholder="6379" disabled="{{ $isPublic }}" id="publicPort"
label="Public port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
@@ -68,26 +68,29 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Public access"
<x-application.settings-section title="Public access" class="relative"
description="Expose this database through the managed TCP proxy.">
<x-slot:actions>
@if ($isPublic)
<x-slide-over fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
</x-slide-over>
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
</x-process-dialog>
@endif
</x-slot:actions>
<x-table.loading target="instantSave" text="Updating public access..." />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => 'Public through TCP proxy'],
]" />
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
</div>
<x-forms.input type="number" placeholder="3306" disabled="{{ $isPublic }}" id="publicPort"
label="Public port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
@@ -65,26 +65,29 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Public access"
<x-application.settings-section title="Public access" class="relative"
description="Expose this database through the managed TCP proxy.">
<x-slot:actions>
@if ($isPublic)
<x-slide-over fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
</x-slide-over>
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
</x-process-dialog>
@endif
</x-slot:actions>
<x-table.loading target="instantSave" text="Updating public access..." />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => 'Public through TCP proxy'],
]" />
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
</div>
<x-forms.input type="number" placeholder="27017" disabled="{{ $isPublic }}" id="publicPort"
label="Public port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
@@ -68,26 +68,29 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Public access"
<x-application.settings-section title="Public access" class="relative"
description="Expose this database through the managed TCP proxy.">
<x-slot:actions>
@if ($isPublic)
<x-slide-over fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
</x-slide-over>
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
</x-process-dialog>
@endif
</x-slot:actions>
<x-table.loading target="instantSave" text="Updating public access..." />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => 'Public through TCP proxy'],
]" />
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
</div>
<x-forms.input type="number" placeholder="3306" disabled="{{ $isPublic }}" id="publicPort"
label="Public port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
@@ -2,7 +2,7 @@
<form wire:submit="submit" class="flex flex-col gap-6">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Database details"
<x-application.settings-section id="database-details-section" title="Database details"
description="Manage the identity and container image for this PostgreSQL database.">
<x-slot:actions>
<x-modal-input title="Resource details" buttonTitle="Details">
@@ -19,7 +19,7 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Credentials"
<x-application.settings-section id="credentials-section" title="Credentials"
description="Keep these values aligned with the credentials configured inside PostgreSQL.">
@if ($database->started_at)
<x-callout type="warning" title="Keep credentials synchronized">
@@ -43,7 +43,7 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Initialization"
<x-application.settings-section id="initialization-section" title="Initialization"
description="Configure the options used when PostgreSQL creates its initial data directory.">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input label="Initial database arguments" id="postgresInitdbArgs"
@@ -53,7 +53,7 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Runtime and network"
<x-application.settings-section id="runtime-network-section" title="Runtime and network"
description="Configure Docker runtime options and host port mappings.">
<div class="grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2">
@@ -72,26 +72,29 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Public access"
<x-application.settings-section id="public-access-section" title="Public access" class="relative"
description="Expose this database through the managed TCP proxy.">
<x-slot:actions>
@if ($isPublic)
<x-slide-over fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
</x-slide-over>
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
</x-process-dialog>
@endif
</x-slot:actions>
<x-table.loading target="instantSave" text="Updating public access..." />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => 'Public through TCP proxy'],
]" />
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
</div>
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
label="Public port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
@@ -100,13 +103,13 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Configuration"
<x-application.settings-section id="configuration-section" title="Configuration"
description="Override the PostgreSQL configuration used by this container.">
<x-forms.textarea label="Custom PostgreSQL configuration" rows="10" id="postgresConf"
canGate="update" :canResource="$database" />
</x-application.settings-section>
<x-application.settings-section title="Log delivery"
<x-application.settings-section id="log-delivery-section" title="Log delivery"
description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[
@@ -116,7 +119,7 @@
</x-application.settings-section>
</form>
<x-application.settings-section title="Initialization scripts"
<x-application.settings-section id="initialization-scripts-section" title="Initialization scripts"
description="Run SQL files in order when PostgreSQL initializes for the first time." flush>
<x-slot:actions>
@can('update', $database)
@@ -67,26 +67,29 @@
</div>
</x-application.settings-section>
<x-application.settings-section title="Public access"
<x-application.settings-section title="Public access" class="relative"
description="Expose this database through the managed TCP proxy.">
<x-slot:actions>
@if ($isPublic)
<x-slide-over fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
</x-slide-over>
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
</x-process-dialog>
@endif
</x-slot:actions>
<x-table.loading target="instantSave" text="Updating public access..." />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => 'Public through TCP proxy'],
]" />
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" />
</div>
<x-forms.input type="number" placeholder="6379" disabled="{{ $isPublic }}" id="publicPort"
label="Public port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
@@ -76,6 +76,9 @@
$backupRoute = $type === 'database'
? route('project.database.backup.execution', [...$parameters, 'backup_uuid' => $backup->uuid])
: route('project.service.database.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
$backupExecutionsRoute = $type === 'database'
? route('project.database.backup.executions', [...$parameters, 'backup_uuid' => $backup->uuid])
: route('project.service.database.backup.executions', [...$parameters, 'backup_uuid' => $backup->uuid]);
@endphp
<div x-show="search === ''
|| @js(strtolower($database->name)).includes(search.toLowerCase())
@@ -97,10 +100,13 @@
{{ $backup->save_s3 ? ($backup->s3?->name ?? 'Unavailable') : 'Local only' }}
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
{{ $backup->executions_count ?? $backup->executions()->count() }}
<a wire:navigate href="{{ $backupExecutionsRoute }}"
class="font-medium hover:underline hover:text-black dark:hover:text-fg">
{{ $backup->executions_count ?? $backup->executions()->count() }}
</a>
</div>
<div class="flex justify-end">
<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>
<a class="button" wire:navigate href="{{ $backupRoute }}">Manage</a>
</div>
</div>
@endforeach
@@ -1,4 +1,4 @@
<div class="mt-8 w-full max-w-[1180px] lg:mt-3">
<div class="mt-8 w-full lg:mt-3">
<form wire:submit="submit">
<section class="application-settings-section">
<div class="application-settings-section-header">
@@ -6,9 +6,9 @@
<h2>Docker Compose</h2>
<p>Create a multi-container service directly from a Compose file.</p>
</div>
<x-forms.button type="submit" isHighlighted>Create service</x-forms.button>
<x-forms.button type="submit" wire:target="submit" isHighlighted>Create service</x-forms.button>
</div>
<div class="application-settings-section-body p-0!">
<div class="application-settings-section-body">
<x-forms.textarea useMonacoEditor monacoEditorLanguage="yaml" label="Docker Compose file"
rows="20" id="dockerComposeRaw" autofocus placeholder='services:
app:
@@ -14,9 +14,14 @@
placeholder="nginx, ghcr.io/user/app:v1.2.3, or nginx:stable@sha256:…"
helper="Paste a complete image reference, or enter a name and use one of the optional fields below."
required autofocus />
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-end"
aria-label="Tag and SHA256 digest are mutually exclusive">
<x-forms.input id="imageTag" label="Tag" placeholder="latest"
helper="Use a mutable tag such as latest or v1.2.3." />
<div
class="flex items-center justify-center text-xs font-semibold text-neutral-400 sm:h-9 dark:text-fg-faint">
<span>OR</span>
</div>
<x-forms.input id="imageSha256" label="SHA256 digest"
placeholder="59e02939b1bf39f16c93138a28727aec…"
helper="Use the 64-character digest without the sha256: prefix." />
@@ -31,8 +31,9 @@
<div class="application-settings-section-body p-0!">
@foreach ($github_apps as $ghapp)
<button type="button"
class="group flex w-full items-center gap-3 border-b border-neutral-200 px-4 py-3 text-left transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.06] dark:hover:bg-white/[0.025]"
class="group relative flex w-full items-center gap-3 border-b border-neutral-200 px-4 py-3 text-left transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.06] dark:hover:bg-white/[0.025]"
wire:click.prevent="loadRepositories({{ $ghapp->id }})"
wire:loading.class="coolbox-loading"
wire:loading.attr="disabled" wire:target="loadRepositories({{ $ghapp->id }})"
wire:key="{{ $ghapp->id }}">
<div
@@ -71,13 +72,13 @@
<div class="application-settings-section-body">
@if ($repositories->isNotEmpty())
<div class="flex items-end gap-2">
<x-forms.datalist class="w-full" label="Repository"
placeholder="Search repositories" wire:model.live="selected_repository_id">
@foreach ($repositories as $repo)
<option value="{{ data_get($repo, 'id') }}">{{ data_get($repo, 'name') }}</option>
@endforeach
</x-forms.datalist>
<x-forms.listbox id="selected_repository_id" label="Repository" required live
:options="$repositories->map(fn ($repository) => [
'value' => data_get($repository, 'id'),
'label' => data_get($repository, 'name'),
])->values()->all()" />
<x-forms.button :showLoadingIndicator="false" wire:click.prevent="loadBranches"
wire:loading.attr="disabled"
wire:target="loadBranches,selected_repository_id">
<x-loading-on-button wire:loading.delay
wire:target="loadBranches,selected_repository_id" />
@@ -99,7 +100,7 @@
<h2>Build configuration</h2>
<p>Choose the branch and build strategy for this application.</p>
</div>
<x-forms.button type="submit" isHighlighted>Continue</x-forms.button>
<x-forms.button type="submit" wire:target="submit" isHighlighted>Continue</x-forms.button>
</div>
<div class="application-settings-section-body space-y-5">
<div class="grid gap-4 sm:grid-cols-2">
@@ -7,15 +7,17 @@
<p>Connect a GitLab App before selecting a private repository.</p>
</div>
</div>
<x-empty title="No GitLab Apps"
description="Create an app to grant Coolify access to selected repositories."
icon-name="sources">
<x-slot:contents>
<x-modal-input buttonTitle="+ Add GitLab App" title="New GitLab App" closeOutside="false">
<livewire:source.gitlab.create />
</x-modal-input>
</x-slot:contents>
</x-empty>
<div class="application-settings-section-body">
<x-empty title="No GitLab Apps"
description="Create an app to grant Coolify access to selected repositories."
icon-name="sources">
<x-slot:contents>
<x-modal-input buttonTitle="+ Add GitLab App" title="New GitLab App" closeOutside="false">
<livewire:source.gitlab.create />
</x-modal-input>
</x-slot:contents>
</x-empty>
</div>
</section>
@elseif ($current_step === 'gitlab_apps')
<section class="application-settings-section">
@@ -6,7 +6,7 @@
<h2>Dockerfile</h2>
<p>Create an application directly from a Dockerfile without connecting a Git repository.</p>
</div>
<x-forms.button type="submit" isHighlighted>Create application</x-forms.button>
<x-forms.button type="submit" wire:target="submit" isHighlighted>Create application</x-forms.button>
</div>
<div class="application-settings-section-body p-0!">
<x-forms.textarea useMonacoEditor monacoEditorLanguage="dockerfile" rows="20"
@@ -172,7 +172,7 @@
</div>
@else
<div wire:key="service-domains-list"
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-hidden">
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible">
@foreach ($domainGroups as $appId => $rows)
@php
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
@@ -190,21 +190,14 @@
<div class="flex w-full items-center gap-3 px-4 py-3">
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">{{ $heading }}</span>
@can('update', $service)
<div class="relative flex shrink-0 items-center gap-2 px-1 py-1 text-sm text-neutral-600 dark:text-fg-dim"
<div class="w-52 shrink-0"
wire:loading.class="opacity-50" wire:target="serviceRedirects.{{ $appId }}">
<span>{{ $redirectLabel }}</span>
<x-reicon name="chevron-down" class="size-4 shrink-0"
wire:loading.remove wire:target="serviceRedirects.{{ $appId }}" />
<x-loading-on-button wire:loading.delay wire:target="serviceRedirects.{{ $appId }}" />
<select id="service-domain-redirect-{{ $appId }}"
wire:model.change="serviceRedirects.{{ $appId }}"
wire:loading.attr="disabled" wire:target="serviceRedirects.{{ $appId }}"
class="absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-wait"
aria-label="Redirect direction for {{ $heading }}">
<option value="both">Allow www & non-www</option>
<option value="www">Redirect to www</option>
<option value="non-www">Redirect to non-www</option>
</select>
<x-forms.listbox id="serviceRedirects.{{ $appId }}"
htmlId="service-domain-redirect-{{ $appId }}" live :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
@else
<span class="shrink-0 text-sm text-neutral-600 dark:text-fg-dim">{{ $redirectLabel }}</span>
@@ -1,66 +1,34 @@
<div x-data="{
raw: true,
showNormalTextarea: false,
editorHeight: 400,
calculateEditorHeight() {
// Get viewport height
const viewportHeight = window.innerHeight;
// Modal max height is calc(100vh - 2rem) = viewport - 32px
const modalMaxHeight = viewportHeight - 32;
// Account for: modal header (~80px) + info text (~60px) + checkboxes (~80px) + buttons (~80px) + padding (~48px)
const fixedElementsHeight = 348;
// Calculate available height for editor
const availableHeight = modalMaxHeight - fixedElementsHeight;
// Set minimum height of 300px and maximum of available space
this.editorHeight = Math.max(300, Math.min(availableHeight, viewportHeight - 200));
}
}" x-init="calculateEditorHeight(); window.addEventListener('resize', () => calculateEditorHeight())">
<div class="pb-4">Volume names are updated upon save. The service UUID will be added as a prefix to all volumes, to
prevent
name collision. <br>To see the actual volume names, check the Deployable Compose file, or go to Storage
menu.</div>
<div x-data="{ raw: true, showNormalTextarea: false }"
@compose-preview-toggle.window="raw = !raw"
@compose-validate.window="$wire.validateCompose()"
@compose-save.window="$wire.saveEditedCompose()"
class="flex min-h-0 flex-col gap-3">
<x-callout type="info" title="Volume names">
Volume names are prefixed with the service UUID when you save to prevent collisions.
</x-callout>
<div class="compose-editor-container" x-bind:style="`--editor-height: ${editorHeight}px`">
<div class="compose-editor-container min-h-[24rem] overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-white/[0.10] dark:bg-[#0b0b0c]"
style="--editor-height: clamp(24rem, calc(100dvh - 25rem), 48rem)">
<div x-cloak x-show="raw" class="font-mono">
<div x-cloak x-show="showNormalTextarea">
<x-forms.textarea x-bind:style="`height: ${editorHeight}px`" id="dockerComposeRaw">
</x-forms.textarea>
<x-forms.textarea class="min-h-[24rem] font-mono" style="height: var(--editor-height)"
id="dockerComposeRaw" />
</div>
<div x-cloak x-show="!showNormalTextarea">
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" id="dockerComposeRaw">
</x-forms.textarea>
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" id="dockerComposeRaw" />
</div>
</div>
<div x-cloak x-show="raw === false" class="font-mono">
<x-forms.textarea x-bind:style="`height: ${editorHeight}px`" readonly id="dockerCompose">
</x-forms.textarea>
<x-forms.textarea class="min-h-[24rem] font-mono" style="height: var(--editor-height)" readonly
id="dockerCompose" />
</div>
</div>
<div class="pt-2 flex gap-2">
<div class="flex flex-col gap-2">
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="isContainerLabelEscapeEnabled" instantSave></x-forms.checkbox>
<x-forms.checkbox label="Show Normal Textarea" x-model="showNormalTextarea"></x-forms.checkbox>
</div>
<div
class="flex flex-wrap items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 p-1 dark:border-white/[0.08] dark:bg-white/[0.025]">
<x-forms.checkbox label="Escape special characters in labels"
helper="By default, $ (and other characters) is escaped. A $ in a label is saved as $$. Turn this off to use environment variables inside labels."
id="isContainerLabelEscapeEnabled" instantSave />
<x-forms.checkbox label="Use plain-text editor" id="showNormalTextarea" x-model="showNormalTextarea" />
</div>
<div class="flex w-full gap-2 pt-4">
<div x-cloak x-show="raw">
<x-forms.button class="w-64" @click.prevent="raw = !raw">Show Deployable Compose</x-forms.button>
</div>
<div x-cloak x-show="raw === false">
<x-forms.button class="w-64" @click.prevent="raw = !raw">Show Source
Compose</x-forms.button>
</div>
<div class="flex-1"></div>
@if (blank($service->service_type))
<x-forms.button class="w-28" wire:click.prevent='validateCompose'>
Validate
</x-forms.button>
@endif
<x-forms.button class="w-28" wire:click.prevent='saveEditedCompose'>
Save
</x-forms.button>
</div>
</div>
</div>
@@ -78,7 +78,7 @@
:aria-expanded="open" aria-haspopup="menu">
<span class="inline-flex items-center gap-2">
<x-loading-on-button x-show="deploying" x-cloak />
<x-reicon name="play-circle" class="size-3.5 opacity-70" x-show="!deploying" />
<x-reicon name="play-circle" class="size-3.5 text-warning" x-show="!deploying" />
<span x-text="deploying ? 'Deploying…' : 'Actions'">Actions</span>
</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
@@ -189,7 +189,7 @@
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
<x-reicon name="play-circle" class="size-3.5 text-warning" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
@@ -285,14 +285,14 @@
<div class="flex items-center gap-2">
<x-loading wire:loading wire:target="instantSave" />
@if ($serviceDatabase->is_public)
<x-slide-over fullScreen>
<x-process-dialog closeWithX size="xl">
<x-slot:title>Proxy Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$service"
:servicesubtype="$serviceDatabase" container="{{ $serviceDatabase->uuid }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
<x-forms.button @click="processDialogOpen = true">Logs</x-forms.button>
</x-process-dialog>
@endif
</div>
</div>
@@ -38,13 +38,6 @@
</p>
@endif
@if ($isApplication && $resource->fqdn)
<div class="mt-2 min-w-0">
<span class="min-w-0 truncate text-xs text-neutral-500 dark:text-fg-dim">
{{ $resource->fqdn }}
</span>
</div>
@endif
</div>
</div>
@@ -91,9 +84,6 @@
</div>
<div class="min-w-0">
<div class="truncate text-[13px] font-semibold text-black dark:text-fg">{{ $resourceName }}</div>
@if ($isApplication && $resource->fqdn)
<div class="truncate text-[11px] text-neutral-500 sm:hidden dark:text-fg-faint">{{ $resource->fqdn }}</div>
@endif
</div>
</div>
<div class="hidden truncate font-mono text-xs text-neutral-500 sm:block dark:text-fg-faint">
@@ -9,7 +9,26 @@
<x-status-badge label="Parser {{ $service->compose_parsing_version }}" type="neutral" />
@endif
@can('update', $service)
<x-modal-input buttonTitle="Edit Compose file" title="Edit Docker Compose" :closeOutside="false">
<x-modal-input buttonTitle="Edit Compose file" title="Docker Compose" :closeOutside="false"
:isLarge="true">
<x-slot:headerActions>
<div x-data="{ preview: false, saving: false }"
@compose-save-finished.window="saving = false" class="flex items-center gap-2">
<x-forms.button
@click="preview = !preview; $dispatch('compose-preview-toggle')">
<x-reicon name="eye" class="size-3.5" />
<span x-text="preview ? 'Back to source Compose' : 'Preview generated Compose'"></span>
</x-forms.button>
@if (blank($service->service_type))
<x-forms.button @click="$dispatch('compose-validate')">Validate</x-forms.button>
@endif
<x-forms.button @click="saving = true; $dispatch('compose-save')"
x-bind:disabled="saving" isHighlighted>
<x-loading-on-button x-show="saving" x-cloak />
Save changes
</x-forms.button>
</div>
</x-slot:headerActions>
<livewire:project.service.edit-compose serviceId="{{ $service->id }}" />
</x-modal-input>
@endcan
@@ -33,6 +33,8 @@
</div>
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
<a href="{{ route('server.show', ['server_uuid' => data_get($resource, 'destination.server.uuid')]) }}"
{{ wireNavigate() }} class="button">Open server</a>
<x-status-summary :status="$resource->status" />
@if ($hasAdditionalDestinations)
<x-forms.button canGate="deploy" :canResource="$resource"
@@ -86,6 +88,8 @@
</div>
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
<a href="{{ route('server.show', ['server_uuid' => data_get($destination, 'server.uuid')]) }}"
{{ wireNavigate() }} class="button">Open server</a>
@if ($destinationStatus->startsWith('running'))
<x-status.running :status="$destinationStatus->value()" />
@elseif ($destinationStatus->startsWith(['starting', 'restarting']))
@@ -211,6 +215,8 @@
</div>
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
<a href="{{ route('server.show', ['server_uuid' => data_get($resource, 'destination.server.uuid')]) }}"
{{ wireNavigate() }} class="button">Open server</a>
@if ($primaryStatus->startsWith('running'))
<x-status.running :status="$primaryStatus->value()" />
@elseif ($primaryStatus->startsWith(['starting', 'restarting']))
@@ -1,4 +1,4 @@
<div class="{{ $collapsible ? 'runtime-log-shell' : '' }}">
<div @class(['w-full min-w-0', 'runtime-log-shell' => $collapsible])>
<div id="screen" x-data="{
collapsible: {{ $collapsible ? 'true' : 'false' }},
expanded: {{ ($expandByDefault || !$collapsible) ? 'true' : 'false' }},
@@ -280,10 +280,6 @@
@else
<x-application.settings-section id="server-metrics-overview-section" title="Metrics"
helper="Inspect recent CPU and memory usage reported by Sentinel.">
<x-slot:actions>
<x-status-badge status="Unavailable" type="warning" />
</x-slot:actions>
<x-empty size="sm" title="Sentinel is required"
description="Enable Sentinel before collecting CPU and memory metrics for this server."
icon-name="dashboard">
@@ -71,12 +71,12 @@
You do not have permission to configure Cloudflare Tunnel for this server.
</x-callout>
@else
<x-slide-over @automated.window="slideOverOpen = true" fullScreen>
<x-process-dialog @automated.window="processDialogOpen = true" closeWithX size="xl">
<x-slot:title>Cloudflare Tunnel Configuration</x-slot:title>
<x-slot:content>
<livewire:activity-monitor header="Logs" fullHeight />
</x-slot:content>
</x-slide-over>
</x-process-dialog>
<form @submit.prevent="$wire.dispatch('automatedCloudflareConfig')">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="cloudflare_token" required label="Cloudflare token"
@@ -3,38 +3,21 @@
New Server | Coolify
</x-slot>
<div class="mb-4 flex flex-wrap items-center justify-end gap-2">
<a href="{{ $selectedType ? route('server.create') : route('server.index') }}" class="button"
{{ wireNavigate() }}>
{{ $selectedType ? 'Change method' : 'Back to servers' }}
</a>
@if ($selectedType && $selectedType !== 'manual' && ! $selectedTokenUuid)
@php
$tokenProvider = $selectedType === 'digital-ocean' ? 'digitalocean' : $selectedType;
$tokenProviderName = $selectedType === 'digital-ocean'
? 'DigitalOcean'
: str($selectedType)->headline();
@endphp
@can('create', App\Models\CloudProviderToken::class)
<x-modal-input title="New {{ $tokenProviderName }} token">
<x-slot:content>
<button type="button"
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
New token
</button>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true" :provider="$tokenProvider"
wire:key="new-server-token-{{ $tokenProvider }}" />
</x-modal-input>
@endcan
@endif
<div class="mb-5 flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">New server</h1>
<div class="flex flex-wrap items-center gap-2">
@if ($selectedType)
<a href="{{ route('server.create') }}" class="button" {{ wireNavigate() }}>
Change method
</a>
@endif
</div>
</div>
@if (!$selectedType)
<div class="application-settings-form">
<x-application.settings-section title="Add a server"
description="Provision with a cloud provider or connect any reachable Linux server." flush>
<section class="application-settings-section">
<div class="application-settings-section-body is-flush">
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
@can('viewAny', App\Models\CloudProviderToken::class)
<a href="{{ route('server.create.type', ['type' => 'hetzner']) }}"
@@ -115,7 +98,8 @@
</div>
</a>
</div>
</x-application.settings-section>
</div>
</section>
</div>
@else
<div class="application-settings-form">
@@ -24,7 +24,6 @@
'uuid' => $server->uuid,
'name' => $server->name,
'description' => $server->description ?: 'No description',
'address' => $server->ip,
'href' => route('server.show', ['server_uuid' => $server->uuid]),
'status' => $isReady ? 'Ready' : ($server->settings->force_disabled ? 'Disabled' : 'Validation required'),
'statusType' => $isReady ? 'success' : 'error',
@@ -41,7 +40,7 @@
const query = this.search.trim().toLowerCase();
if (!query) return this.servers;
return this.servers.filter(server =>
[server.name, server.description, server.address, server.status]
[server.name, server.description, server.status]
.some(value => String(value || '').toLowerCase().includes(query))
);
},
@@ -109,7 +108,7 @@
<h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg"
x-text="server.name"></h2>
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint"
x-text="server.address"></p>
x-text="server.description"></p>
</div>
</div>
<div class="mt-auto flex items-center pt-4">
@@ -126,14 +125,13 @@
<div x-show="viewMode === 'table'"
class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div
class="grid min-w-[680px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_9.5rem] 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.025] dark:text-fg-faint">
class="grid min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] 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.025] dark:text-fg-faint">
<div>Server</div>
<div>Address</div>
<div>Status</div>
</div>
<template x-for="server in filteredServers" :key="server.uuid">
<a :href="server.href" {{ wireNavigate() }}
class="grid min-h-14 min-w-[680px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_9.5rem] 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]">
class="grid min-h-14 min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] 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]">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim">
@@ -146,7 +144,6 @@
x-text="server.description"></p>
</div>
</div>
<div class="truncate text-neutral-500 dark:text-fg-dim" x-text="server.address"></div>
<div>
<x-status-badge dynamic>
<span class="size-1.5 rounded-full"
@@ -86,6 +86,7 @@
$serverRouteParameters,
);
$showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled();
$proxyCanBeStopped = in_array($proxyStatus, ['running', 'starting', 'restarting'], true);
@endphp
@teleport('#server-topbar-context')
@@ -166,7 +167,7 @@
<div x-cloak x-show="open" x-transition.origin.top.left
class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="menu">
@if ($proxyStatus === 'running')
@if ($proxyCanBeStopped)
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()"
role="menuitem">
@@ -277,7 +278,7 @@
@can('manageProxy', $server)
<div
class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($proxyStatus === 'running')
@if ($proxyCanBeStopped)
<div class="mt-1" wire:loading wire:target="loadProxyConfiguration">
<x-loading text="Checking Traefik dashboard" />
</div>
@@ -317,7 +318,8 @@
</x-slot:content>
</x-modal-confirmation>
@else
<x-forms.button @click="$wire.dispatch('checkProxyEvent')">
<x-forms.button @click="$wire.dispatch('checkProxyEvent')"
wire:target="checkProxy,startProxy">
<x-reicon name="play-circle"
class="size-4 text-coollabs dark:text-warning" />
Start Proxy
@@ -2,8 +2,16 @@
@if ($limit_reached)
<x-limit-reached name="servers" />
@elseif ($current_step === 1)
<x-server.provider-token-picker provider="digitalocean" providerLabel="DigitalOcean"
routeType="digital-ocean" :tokens="$available_tokens" />
<div class="flex flex-col gap-6">
<x-server.provider-token-picker provider="digitalocean" providerLabel="DigitalOcean"
routeType="digital-ocean" :tokens="$available_tokens" />
<p class="text-[11px] text-neutral-500 dark:text-fg-faint">
New to DigitalOcean?
<a href="https://coolify.io/digitalocean" target="_blank"
class="font-medium text-coollabs hover:underline dark:text-warning">Create an account</a>
through Coolify's referral link.
</p>
</div>
@elseif ($current_step === 2)
<div wire:init="loadDigitalOceanData">
@if ($loading_data)
@@ -20,28 +20,17 @@
</button>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="name" label="Name" required />
<x-forms.input id="description" label="Description" />
</div>
<div class="mt-5 grid gap-4 border-t border-neutral-200 pt-4 lg:grid-cols-3 dark:border-white/[0.08]">
<div class="mb-5">
<x-forms.input id="ip" label="IP address or domain" required
helper="For example 127.0.0.1 or server.example.com." />
<x-forms.input id="user" label="User" required
helper="Non-root SSH users are experimental." />
<x-forms.input type="number" id="port" label="Port" required />
</div>
<div class="mt-5 grid items-end gap-4 border-t border-neutral-200 pt-4 lg:grid-cols-2 dark:border-white/[0.08]">
<x-forms.listbox id="private_key_id" label="Private key"
placeholder="Select a private key" :options="$privateKeyOptions" />
<div class="flex items-center justify-between gap-3">
<x-forms.checkbox id="is_build_server"
helper="Build servers compile applications but do not host deployments."
label="Use as a build server" />
<div class="mb-5">
<div class="flex items-end gap-3">
<div class="min-w-0 flex-1">
<x-forms.listbox id="private_key_id" label="Private key"
placeholder="Select a private key" :options="$privateKeyOptions" />
</div>
@can('create', App\Models\PrivateKey::class)
<div x-data="{ dropdownOpen: false }" class="relative shrink-0"
@click.outside="dropdownOpen = false"
@@ -81,6 +70,23 @@
@endcan
</div>
</div>
<div class="grid gap-4 border-t border-neutral-200 pt-4 lg:grid-cols-2 dark:border-white/[0.08]">
<x-forms.input id="name" label="Name" required />
<x-forms.input id="description" label="Description" />
</div>
<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"
content-class="flex flex-col gap-4">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="user" label="User" required
helper="Non-root SSH users are experimental." />
<x-forms.input type="number" id="port" label="Port" required />
</div>
<x-forms.checkbox id="is_build_server"
helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."
label="Use as a dedicated build server" />
</x-forms.collapsible>
</x-application.settings-section>
</form>
@endif
@@ -2,8 +2,16 @@
@if ($limit_reached)
<x-limit-reached name="servers" />
@elseif ($current_step === 1)
<x-server.provider-token-picker provider="vultr" providerLabel="Vultr"
:tokens="$available_tokens" />
<div class="flex flex-col gap-6">
<x-server.provider-token-picker provider="vultr" providerLabel="Vultr"
:tokens="$available_tokens" />
<p class="text-[11px] text-neutral-500 dark:text-fg-faint">
New to Vultr?
<a href="https://coolify.io/vultr" target="_blank"
class="font-medium text-coollabs hover:underline dark:text-warning">Create an account</a>
through Coolify's affiliate link.
</p>
</div>
@elseif ($current_step === 2)
<div wire:init="loadVultrData">
@if ($loading_data)
@@ -13,12 +13,14 @@
flush>
<x-slot:actions>
<div class="inline-flex w-fit rounded-[10px] bg-neutral-100 p-1 dark:bg-white/[0.05]">
<button type="button" wire:click="loadManagedContainers"
class="rounded-md px-3 py-1 text-xs font-medium transition-colors {{ $activeTab === 'managed' ? 'bg-white text-neutral-950 shadow-sm dark:bg-warning/15 dark:text-warning' : 'text-neutral-500 hover:text-neutral-900 dark:text-fg-dim dark:hover:text-fg' }}">
<button type="button" wire:click="loadManagedContainers" wire:loading.attr="disabled" wire:target="loadManagedContainers,loadUnmanagedContainers"
class="inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors disabled:cursor-wait {{ $activeTab === 'managed' ? 'bg-white text-neutral-950 shadow-sm dark:bg-warning/15 dark:text-warning' : 'text-neutral-500 hover:text-neutral-900 dark:text-fg-dim dark:hover:text-fg' }}">
<x-loading-on-button wire:loading wire:target="loadManagedContainers" />
Managed
</button>
<button type="button" wire:click="loadUnmanagedContainers"
class="rounded-md px-3 py-1 text-xs font-medium transition-colors {{ $activeTab === 'unmanaged' ? 'bg-white text-neutral-950 shadow-sm dark:bg-warning/15 dark:text-warning' : 'text-neutral-500 hover:text-neutral-900 dark:text-fg-dim dark:hover:text-fg' }}">
<button type="button" wire:click="loadUnmanagedContainers" wire:loading.attr="disabled" wire:target="loadManagedContainers,loadUnmanagedContainers"
class="inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors disabled:cursor-wait {{ $activeTab === 'unmanaged' ? 'bg-white text-neutral-950 shadow-sm dark:bg-warning/15 dark:text-warning' : 'text-neutral-500 hover:text-neutral-900 dark:text-fg-dim dark:hover:text-fg' }}">
<x-loading-on-button wire:loading wire:target="loadUnmanagedContainers" />
Unmanaged
</button>
</div>
@@ -5,12 +5,14 @@
<livewire:server.navbar :server="$server" />
<x-slide-over closeWithX fullScreen @startupdate.window="slideOverOpen = true">
<x-process-dialog @startupdate.window="processDialogOpen = true" closeWithX size="xl">
<x-slot:title>Updating packages</x-slot:title>
<x-slot:content>
<livewire:activity-monitor header="Logs" />
<div class="flex h-full min-h-0 flex-col">
<livewire:activity-monitor header="Logs" fullHeight />
</div>
</x-slot:content>
</x-slide-over>
</x-process-dialog>
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
@@ -13,19 +13,14 @@
helper="Monitor server and container health while collecting historical metrics.">
<x-slot:actions>
<div class="flex items-center gap-2">
<x-status-badge
:status="!$isSentinelEnabled
? 'Disabled'
: ($server->isSentinelLive() ? 'In sync' : 'Out of sync')"
:type="!$isSentinelEnabled
? 'neutral'
: ($server->isSentinelLive() ? 'success' : 'warning')" />
@if (!$isSentinelEnabled)
<x-forms.button canGate="update" :canResource="$server" isHighlighted
wire:click="toggleSentinel">
Enable Sentinel
</x-forms.button>
@else
<x-status-badge :status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
:type="$server->isSentinelLive() ? 'success' : 'warning'" />
<x-forms.button wire:click="restartSentinel" canGate="update"
:canResource="$server">
<x-reicon name="refresh" class="size-3.5" />
+10 -19
View File
@@ -192,17 +192,18 @@
</div>
@endif
<x-slide-over closeWithX fullScreen>
<x-process-dialog closeWithX size="xl" :open="$isValidating">
<x-slot:title>Validate and configure</x-slot:title>
<x-slot:content>
<livewire:server.validate-and-install :server="$server" :ask="$server->isFunctional()" />
<livewire:server.validate-and-install :server="$server"
:ask="$server->isFunctional() && ! $isValidating" />
</x-slot:content>
<x-forms.button type="button"
@click="slideOverOpen=true" wire:click.prevent="validateServer">
<x-reicon name="refresh" class="size-3.5" />
<x-forms.button type="button" :isHighlighted="! $server->isFunctional()"
@click="processDialogOpen = true" wire:click.prevent="validateServer">
<x-reicon :name="$server->isFunctional() ? 'refresh' : 'alert-circle'" class="size-3.5" />
{{ $server->isFunctional() ? 'Revalidate connection' : 'Validate connection' }}
</x-forms.button>
</x-slide-over>
</x-process-dialog>
</x-slot:actions>
@if ($this->limaStartCommand)
@@ -261,10 +262,11 @@
@if ($isBuildServerLocked)
<x-forms.checkbox disabled id="isBuildServer"
helper="This server already hosts resources and cannot become build-only."
label="Use as a build server" />
label="Use as a dedicated build server" />
@else
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
id="isBuildServer" label="Use as a build server"
id="isBuildServer" label="Use as a dedicated build server"
helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."
:disabled="$isValidating" />
@endif
</div>
@@ -282,17 +284,6 @@
@endif
</form>
@endif
@if ($isValidating)
<div x-data="{ slideOverOpen: true }">
<x-slide-over closeWithX fullScreen>
<x-slot:title>Validation in progress</x-slot:title>
<x-slot:content>
<livewire:server.validate-and-install :server="$server" />
</x-slot:content>
</x-slide-over>
</div>
@endif
</div>
</div>
</div>
@@ -21,6 +21,12 @@
$showDocker = (bool) ($uptime && $supported_os_type && $prerequisites_installed);
$showCompose = $showDocker;
$showVersion = (bool) ($showDocker && $docker_compose_installed);
$validationComplete = (bool) ($uptime
&& $supported_os_type
&& $prerequisites_installed
&& $docker_installed
&& $docker_compose_installed
&& $docker_version);
$checkpoints = [
[
@@ -66,7 +72,7 @@
];
@endphp
<div class="flex flex-col gap-4">
<div class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto scrollbar">
@if ($ask)
<div
class="rounded-[10px] border border-neutral-200 bg-neutral-50 px-4 py-3 text-[13px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
@@ -77,28 +83,36 @@
Continue
</x-forms.button>
@else
<section class="application-settings-section">
<header>
<div class="flex items-center gap-2">
<h3>Validation checkpoints</h3>
</div>
</header>
<div class="application-settings-section-body is-flush">
<div class="divide-y divide-neutral-200 dark:divide-white/[0.07]">
@foreach ($checkpoints as $checkpoint)
@continue(! $checkpoint['visible'])
<x-checkpoint-item :title="$checkpoint['title']" :description="$checkpoint['description']"
:status="$checkpoint['status']" />
@endforeach
</div>
<div data-validation-checkpoints
class="overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]">
<div class="border-b border-neutral-200 px-4 py-2.5 dark:border-white/[0.08]">
<h3 class="text-[13px] font-medium text-neutral-600 dark:text-fg-dim">Validation checkpoints</h3>
</div>
</section>
<div class="divide-y divide-neutral-200 dark:divide-white/[0.07]">
@foreach ($checkpoints as $checkpoint)
<x-checkpoint-item :title="$checkpoint['title']" :description="$checkpoint['description']"
:status="$checkpoint['status']" />
@endforeach
</div>
</div>
<section class="application-settings-section">
<div class="application-settings-section-body">
<livewire:activity-monitor :header="$installationStep.' installation logs'" :showWaiting="false" />
@if ($validationComplete)
<div class="mt-auto flex shrink-0 items-center justify-between gap-3 rounded-[10px] border border-emerald-500/20 bg-emerald-500/[0.06] px-4 py-3">
<div class="flex items-center gap-2 text-[13px] font-medium text-emerald-700 dark:text-emerald-300">
<x-reicon name="check-circle" class="size-4 shrink-0" />
Validation complete
</div>
<x-forms.button type="button" @click="processDialogOpen = false">
Close
</x-forms.button>
</div>
</section>
@elseif ($isInstalling)
<section class="application-settings-section">
<div class="application-settings-section-body">
<livewire:activity-monitor :header="$installationStep.' installation logs'" :showWaiting="false" />
</div>
</section>
@endif
@isset($error)
<div
@@ -102,7 +102,7 @@
}
}">
@if ($selected_uuid === 'default')
<div data-terminal-target-canvas
<div wire:key="terminal-target-canvas" data-terminal-target-canvas
class="application-console-shell relative flex h-full min-h-0 w-full items-center justify-center overflow-hidden rounded-lg p-6"
:data-console-theme="consoleTheme"
:style="{ '--terminal-scrollbar': themeAccents[consoleTheme] }">
@@ -161,7 +161,7 @@
</div>
</div>
@else
<div data-terminal-session-canvas
<div wire:key="terminal-session-canvas" data-terminal-session-canvas
class="application-console-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg p-3 sm:p-6"
:data-console-theme="consoleTheme"
:style="{ '--terminal-scrollbar': themeAccents[consoleTheme] }">
+2 -1
View File
@@ -40,7 +40,7 @@ test('custom color theme is available and applied across theme controls', functi
->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%, #52525b);')
->toContain('--theme-placeholder-color: color-mix(in srgb, white 82%, var(--theme-base-color));')
->toContain('--theme-placeholder-color: color-mix(in srgb, white 20%, var(--theme-base-color));')
->toContain('html[data-theme="custom"] *')
->toContain('scrollbar-color: var(--theme-scrollbar-thumb) var(--color-panel);')
->toContain('html[data-theme="custom"] *::-webkit-scrollbar-thumb')
@@ -72,6 +72,7 @@ test('custom color theme is available and applied across theme controls', functi
->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: 0\.7;/s')
->toContain('html[data-theme="custom"] input:read-only')
->and($deploymentLogs)
->toContain('dark:bg-log')
@@ -8,3 +8,12 @@ it('uses the shared status summary in the primary application server card', func
->toContain('<x-status-summary :status="$resource->status" />')
->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');
});
+15 -1
View File
@@ -16,5 +16,19 @@ test('compose actions are grouped with the application details header', function
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 class="flex items-center gap-4">');
expect($view)
->toContain('<div x-data="{ showRaw: true }" class="mt-5">')
->toContain('<div class="flex items-center justify-between gap-4">');
});
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"');
});
@@ -0,0 +1,74 @@
<?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:" />');
});
+64
View File
@@ -0,0 +1,64 @@
<?php
use Illuminate\Support\Facades\Blade;
it('uses the large modal treatment for the compose editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/stack-form.blade.php'));
$modal = file_get_contents(resource_path('views/components/modal-input.blade.php'));
expect($view)
->toContain('title="Docker Compose"')
->toContain(':isLarge="true"')
->toContain('<x-slot:headerActions>')
->toContain("\$dispatch('compose-preview-toggle')")
->toContain("\$dispatch('compose-save')")
->toContain('@compose-save-finished.window="saving = false"')
->toContain('<x-loading-on-button x-show="saving" x-cloak />')
->toContain('x-bind:disabled="saving"')
->not->toContain('name="refresh"')
->not->toContain("saving ? 'Saving...' : 'Save changes'")
->not->toContain(' :disabled="saving"')
->toContain('Preview generated Compose')
->toContain('Back to source Compose')
->toContain('Save changes')
->toContain('isHighlighted');
expect($modal)->toContain('lg:w-[95vw]! lg:max-w-7xl!');
});
it('renders the compose editor with clear guidance settings and actions', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/edit-compose.blade.php'));
expect($view)
->toContain('<x-callout type="info" title="Volume names">')
->not->toContain('View the final names')
->toContain('Use plain-text editor')
->toContain('min-h-[24rem]')
->toContain('@compose-preview-toggle.window')
->toContain('@compose-save.window')
->not->toContain("finally(() => \$dispatch('compose-save-finished'))")
->not->toContain('sticky bottom-0')
->not->toContain('Cancel')
->not->toContain('Show Normal Textarea')
->not->toContain('Show Deployable Compose');
});
it('keeps the save button loading until the parent compose save finishes', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/StackForm.php'));
expect($component)
->toContain('public function saveCompose($raw)')
->toContain("\$this->dispatch('compose-save-finished')");
});
it('does not show a saving notification for compose changes', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/EditCompose.php'));
expect($component)->not->toContain("\$this->dispatch('info', 'Saving new docker compose...')");
});
it('keeps the saving state as an Alpine button binding', function () {
$html = Blade::render('<x-forms.button x-bind:disabled="saving">Save changes</x-forms.button>');
expect($html)->toContain('x-bind:disabled="saving"');
});
@@ -5,6 +5,8 @@ it('uses a database list editor instead of a comma-separated text field', functi
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"')
+6 -1
View File
@@ -15,7 +15,12 @@ it('matches the storage backup overview layout', function () {
->toContain('placeholder="Search backups"')
->toContain('data-table overflow-hidden rounded-xl border')
->toContain('<span class="block truncate text-[12px] font-semibold')
->toContain('<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>')
->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')");
});
@@ -0,0 +1,28 @@
<?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');
}
});
@@ -0,0 +1,10 @@
<?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"');
});
@@ -0,0 +1,23 @@
<?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>');
});
@@ -0,0 +1,9 @@
<?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>');
});
+32
View File
@@ -42,6 +42,38 @@ it('insets empty states nested inside a flush settings section wrapper', functio
expect($css)->toContain('.application-settings-section-body.is-flush > div:has(> .empty-state:only-child)');
});
it('insets the backup executions empty state from the table header', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($view)->toMatch('/@empty\s*<div class="p-4">\s*<x-empty size="sm" title="No backup executions"/');
});
it('keeps backup execution actions compact', function () {
$css = file_get_contents(resource_path('css/app.css'));
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($css)
->toContain('grid-template-columns: 6.5rem minmax(7rem, 1fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
->and($view)
->toContain('title="Download backup" aria-label="Download backup"')
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
->toContain('title="Delete backup" aria-label="Delete backup"')
->toContain('<x-reicon name="trash" class="size-3.5" />');
});
it('uses the compact resource table styling for backup executions', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('class="data-table deployment-table-scroll"')
->toContain('data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]')
->toContain('data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5')
->toContain('flex min-h-11 items-center justify-between border-t')
->and($css)
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*49rem;/');
});
it('renders compact size without the full-page min height class', function () {
$html = $this->blade(
'<x-empty title="No cloud tokens" description="Add a provider token." icon-name="keys" size="sm" />'
@@ -105,6 +105,32 @@ describe('GitHub Private Repository Component', function () {
->assertSet('selected_repository_id', 1);
});
test('repository selection uses the shared listbox and disables loading action', function () {
fakeGithubHttp([
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
]);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->assertSee('id="selected_repository_id-trigger"', false)
->assertSee('wire:loading.attr="disabled"', false)
->assertSee('wire:target="loadBranches,selected_repository_id"', false)
->assertDontSee('<datalist', false);
});
test('continue button uses the shared submit loading indicator', function () {
fakeGithubHttp([
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
]);
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->call('loadRepositories', $this->githubApp->id)
->set('branches', collect([['name' => 'main']]))
->assertSee('type="submit"', false)
->assertSee('wire:target="submit"', false)
->assertSee('wire:loading.class="is-loading"', false);
});
test('loadRepositories rejects a github app owned by another team', function () {
$victimTeam = Team::factory()->create();
$victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam);
@@ -46,6 +46,12 @@ beforeEach(function () {
});
describe('GitLab App authorization', function () {
test('empty gitlab app state is inset from the section edges', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/gitlab-private-repository.blade.php'));
expect($view)->toContain("<div class=\"application-settings-section-body\">\n <x-empty title=\"No GitLab Apps\"");
});
test('unrelated users cannot inspect system-wide source secrets in the component payload', function () {
$otherTeam = Team::factory()->create();
$systemWideSource = GitlabApp::create([
@@ -25,3 +25,10 @@ it('uses a single Alpine result renderer for every command palette result type',
->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)');
});
@@ -14,6 +14,14 @@ test('listbox trigger styles constrain width and ellipsize long labels', functio
->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('listbox component uses shared trigger label truncation', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="longOption" label="Example"
@@ -19,3 +19,20 @@ it('dispatches a server navbar refresh after toggling sentinel', function () {
expect($matches['body'] ?? '')
->toContain("\$this->dispatch('refreshServerShow');");
});
it('only marks sentinel enabled after startup succeeds', function () {
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
preg_match('/public function toggleSentinel\([^)]*\).*?\{(?<body>.*?)\n \}/s', $componentSource, $matches);
$toggleBody = $matches['body'] ?? '';
expect(strpos($toggleBody, 'StartSentinel::run'))->toBeLessThan(
strpos($toggleBody, '$this->isSentinelEnabled = true;')
);
});
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'");
});
+13
View File
@@ -1,5 +1,18 @@
<?php
it('uses the themed warning color for resource action icons', function () {
foreach ([
'application',
'database',
'service',
] as $resource) {
$heading = file_get_contents(resource_path("views/livewire/project/{$resource}/heading.blade.php"));
expect(substr_count($heading, 'name="play-circle" class="size-3.5 text-warning"'))
->toBeGreaterThanOrEqual(2);
}
});
it('uses native mobile menus for databases and services', function () {
$applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
+39
View File
@@ -39,3 +39,42 @@ test('process dialog body styles support a full-height log surface', function ()
->toContain('.process-dialog-body')
->toContain('min-height: min(70dvh, 28rem)');
});
test('runtime log terminal fills the available dialog width', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
expect($view)->toContain("<div @class(['w-full min-w-0', 'runtime-log-shell' => \$collapsible])>");
});
test('process and log viewers use centered dialogs', function () {
$paths = [
resource_path('views/livewire/project/service/index.blade.php'),
resource_path('views/livewire/project/database/keydb/general.blade.php'),
resource_path('views/livewire/project/database/redis/general.blade.php'),
resource_path('views/livewire/project/database/postgresql/general.blade.php'),
resource_path('views/livewire/project/database/clickhouse/general.blade.php'),
resource_path('views/livewire/project/database/mongodb/general.blade.php'),
resource_path('views/livewire/project/database/dragonfly/general.blade.php'),
resource_path('views/livewire/project/database/mariadb/general.blade.php'),
resource_path('views/livewire/project/database/mysql/general.blade.php'),
resource_path('views/livewire/server/security/patches.blade.php'),
resource_path('views/livewire/server/cloudflare-tunnel.blade.php'),
];
foreach ($paths as $path) {
expect(file_get_contents($path))
->toContain('<x-process-dialog')
->not->toContain('<x-slide-over');
}
expect(file_get_contents(resource_path('views/livewire/server/show.blade.php')))
->not->toContain('<x-slide-over');
});
test('process dialog can start open for an in-progress operation', function () {
$component = file_get_contents(resource_path('views/components/process-dialog.blade.php'));
expect($component)
->toContain("'open' => false")
->toContain('processDialogOpen: @js($open)');
});
+40 -1
View File
@@ -11,7 +11,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0]);
});
function setupProxyUser(string $role): array
@@ -72,6 +72,25 @@ test('admin can see proxy restart and stop buttons', function () {
->assertSee('Stop Proxy');
});
test('admin can stop a proxy while it is starting', function () {
[$user, $team, $server] = setupProxyUser('admin');
$server->proxy->status = 'starting';
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();
$mock = Mockery::mock($server)->makePartial();
$mock->shouldReceive('proxySet')->andReturn(true);
$this->actingAs($user);
session(['currentTeam' => $team]);
Livewire::test('server.navbar', ['server' => $mock])
->assertSee('Stop Proxy')
->assertDontSee('Start Proxy');
});
test('member cannot see start proxy button', function () {
[$user, $team, $server] = setupProxyUser('member');
@@ -89,3 +108,23 @@ test('member cannot see start proxy button', function () {
Livewire::test('server.navbar', ['server' => $mock])
->assertDontSee('Start Proxy');
});
test('start proxy button shows a loading state while proxy startup actions run', function () {
[$user, $team, $server] = setupProxyUser('admin');
$server->proxy->status = 'exited';
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();
$mock = Mockery::mock($server)->makePartial();
$mock->shouldReceive('proxySet')->andReturn(true);
$this->actingAs($user);
session(['currentTeam' => $team]);
Livewire::test('server.navbar', ['server' => $mock])
->assertSeeHtml('wire:loading.attr="disabled"')
->assertSeeHtml('wire:loading.class="is-loading"')
->assertSeeHtml('wire:target="checkProxy,startProxy"');
});
@@ -15,6 +15,18 @@ it('renders the resource terminal shell while containers are discovered', functi
->not->toContain('<x-loading text="Loading containers" />');
});
it('provides opt-in diagnostics for connected terminal theme changes', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain('terminal-debug')
->toContain("'[Terminal Theme] Applying theme'")
->toContain("'[Terminal Theme] Theme applied'")
->toContain('requestedTheme: themeName')
->toContain('shellTheme: shell?.dataset.consoleTheme')
->toContain("getComputedStyle(shell, '::before').background");
});
it('starts a single discovered resource container without waiting for a missed browser event', function () {
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/ExecuteContainerCommand.php'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
@@ -171,11 +183,12 @@ it('mounts the realtime terminal utilities in local development compose files',
'maxio dev compose' => 'docker-compose-maxio.dev.yml',
]);
it('keeps terminal browser logging restricted to Vite development mode', function () {
it('keeps terminal browser logging restricted to development or explicit diagnostics', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain('const terminalDebugEnabled = import.meta.env.DEV;')
->toContain('const terminalDebugEnabled = import.meta.env.DEV')
->toContain("localStorage.getItem('coolify-terminal-debug') === '1'")
->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');")
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
});
@@ -1,14 +1,18 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
DB::table('instance_settings')->insert(['id' => 0]);
$user = User::factory()->create();
$this->team = $user->teams()->first();
@@ -146,6 +150,38 @@ describe('ServerSetting::ensureValidSentinelToken', function () {
});
});
describe('ServerSetting::ensureSentinelUrl', function () {
it('uses the current private instance URL when no public address is configured', function () {
InstanceSettings::query()->whereKey(0)->update([
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
Once::flush();
DB::table('server_settings')->where('id', $this->server->settings->id)->update(['sentinel_custom_url' => null]);
app()->instance('request', Request::create('http://192.168.1.50:8000/server'));
$url = $this->server->settings->fresh()->ensureSentinelUrl();
expect($url)->toBe('http://192.168.1.50:8000')
->and($this->server->settings->fresh()->sentinel_custom_url)->toBe($url);
});
it('does not use a loopback request URL for a remote server', function () {
InstanceSettings::query()->whereKey(0)->update([
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
Once::flush();
DB::table('server_settings')->where('id', $this->server->settings->id)->update(['sentinel_custom_url' => null]);
app()->instance('request', Request::create('http://localhost:8000/server'));
expect(fn () => $this->server->settings->fresh()->ensureSentinelUrl())
->toThrow(RuntimeException::class, 'Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.');
});
});
describe('generated sentinel tokens are valid', function () {
it('generates tokens that pass format validation', function () {
$settings = $this->server->settings;
@@ -0,0 +1,9 @@
<?php
test('server settings explain dedicated build server mode', function () {
$view = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
expect($view)
->toContain('label="Use as a dedicated build server"')
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
});
@@ -0,0 +1,20 @@
<?php
test('server creation uses the standard page title without redundant navigation', function () {
$view = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
expect($view)
->toContain('<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">New server</h1>')
->toContain('class="mb-5 flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"')
->not->toContain('Back to servers')
->not->toContain('title="Add a server"');
});
test('provider pages only show the token action in the account panel', function () {
$view = file_get_contents(resource_path('views/livewire/server/create.blade.php'));
expect($view)
->not->toContain('New token')
->not->toContain('new-server-token-')
->not->toContain('tokenProviderName');
});
@@ -0,0 +1,20 @@
<?php
test('server creation keeps private key actions together and advanced options collapsed', function () {
$view = file_get_contents(resource_path('views/livewire/server/new/by-ip.blade.php'));
expect($view)
->toContain('class="flex items-end gap-3"')
->toContain('<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"')
->toContain('label="Use as a dedicated build server"')
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
});
test('server creation places the IP address and private key before optional details', function () {
$view = file_get_contents(resource_path('views/livewire/server/new/by-ip.blade.php'));
expect($view)
->toContain('class="mb-5"')
->and(strpos($view, 'id="ip"'))->toBeLessThan(strpos($view, 'id="private_key_id"'))
->and(strpos($view, 'id="private_key_id"'))->toBeLessThan(strpos($view, 'id="name"'));
});
@@ -20,3 +20,13 @@ test('view switchers use the shared coollabs selected state on every page', func
->toContain('@utility control-selected')
->toContain('@apply bg-coollabs text-white dark:bg-coollabs dark:text-white;');
});
test('server index does not expose server IP addresses', function () {
$view = file_get_contents(resource_path('views/livewire/server/index.blade.php'));
expect($view)
->not->toContain("'address' => \$server->ip")
->not->toContain('<div>Address</div>')
->not->toContain('x-text="server.address"')
->not->toContain('server.address,');
});
@@ -0,0 +1,10 @@
<?php
test('sentinel-required metrics state does not repeat an unavailable badge', function () {
$view = file_get_contents(resource_path('views/livewire/server/charts.blade.php'));
$sentinelRequiredState = str($view)->after('@else')->before('@endif')->toString();
expect($sentinelRequiredState)
->toContain('title="Sentinel is required"')
->not->toContain('status="Unavailable"');
});
@@ -55,3 +55,13 @@ test('unmanaged container names use the same typeface as managed resource names'
->toContain('min-w-0 truncate text-[12px] font-medium text-neutral-950 dark:text-fg')
->not->toContain('truncate font-mono text-[12px] text-neutral-950 dark:text-fg');
});
test('server resource tabs show a loading state while switching', function () {
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
expect(substr_count($view, 'wire:loading.attr="disabled" wire:target="loadManagedContainers,loadUnmanagedContainers"'))
->toBe(2)
->and($view)
->toContain('<x-loading-on-button wire:loading wire:target="loadManagedContainers" />')
->toContain('<x-loading-on-button wire:loading wire:target="loadUnmanagedContainers" />');
});
@@ -0,0 +1,56 @@
<?php
test('server revalidation opens in the centered process dialog', function () {
$view = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
expect($view)
->toContain('<x-process-dialog closeWithX size="xl" :open="$isValidating">')
->toContain(':isHighlighted="! $server->isFunctional()"')
->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"')
->not->toContain('<x-slide-over');
});
test('completed server validation shows a close action instead of empty logs', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
expect($view)
->toContain('$validationComplete')
->toContain('mt-auto')
->toContain('<x-forms.button type="button" @click="processDialogOpen = false">')
->toContain('@click="processDialogOpen = false"')
->toContain('Validation complete')
->toContain('Close');
});
test('installation logs are only shown after an installation starts', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
$component = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php'));
expect($view)->toContain('@elseif ($isInstalling)')
->and($component)
->toContain('public bool $isInstalling = false;')
->toContain('$this->isInstalling = true;');
});
test('server validation content scrolls within the dialog', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
$activityMonitor = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php'));
expect($view)->toContain('class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto scrollbar"')
->and($activityMonitor)->toContain("'overflow-hidden' => !\$fullHeight")
->and($activityMonitor)->not->toContain("'h-full overflow-hidden' => !\$fullHeight");
});
test('validation checkpoints use the standard bordered list treatment', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
expect($view)
->toContain('data-validation-checkpoints')
->toContain('overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]');
});
test('all validation checkpoints remain visible while only the current phase runs', function () {
$view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php'));
expect($view)->not->toContain("@continue(! \$checkpoint['visible'])");
});
@@ -54,3 +54,10 @@ it('distinguishes domain management from resource settings', function () {
->toContain('title="Resource settings" aria-label="Resource settings"')
->not->toContain('title="Edit domains" aria-label="Edit domains"');
});
it('does not display application domains on compose resource cards', function () {
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
expect($resourceCard)
->not->toContain('{{ $resource->fqdn }}');
});
@@ -18,6 +18,12 @@ it('moves service and database page navigation into their sidebars', function ()
->toContain("['label' => 'Terminal'");
});
it('uses a full page load for database backup imports', function () {
$sidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
expect($sidebar)->toContain("['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'navigate' => false");
});
it('matches application action bar behavior for services and databases', function () {
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$database = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
@@ -40,7 +46,7 @@ it('keeps database and service sidebar sections in the application sequence', fu
$service = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
expect($database)
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Servers', 'Import Backup']")
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers']")
->toContain("'Automation' => ['Webhooks', 'Healthcheck']")
->toContain("'Logs' => ['Runtime']")
->toContain("'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone']");
+5 -3
View File
@@ -104,9 +104,11 @@ it('groups configured domains with their service redirect and excludes services
expect($html)
->toContain("service-domain-group-{$this->apiApp->id}")
->toContain("service-domain-redirect-{$this->apiApp->id}")
->toContain("wire:model.change=\"serviceRedirects.{$this->apiApp->id}\"")
->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"")
->toContain("id=\"service-domain-redirect-{$this->apiApp->id}-trigger\"")
->toContain("serviceRedirects.{$this->apiApp->id}")
->toContain('class="listbox-trigger"')
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
->not->toContain("<select id=\"service-domain-redirect-{$this->apiApp->id}\"")
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, '2 domains'))->toBe(1)
@@ -44,3 +44,31 @@ test('configuration sidebar subitems trigger section highlight on scroll', funct
->toContain("addEventListener('scrollend'")
->toContain('stableFrames');
});
test('postgresql general navigation lists each in-page settings section', function () {
$sidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
$general = file_get_contents(resource_path('views/livewire/project/database/postgresql/general.blade.php'));
$sections = [
'database-details-section' => 'Database details',
'credentials-section' => 'Credentials',
'initialization-section' => 'Initialization',
'runtime-network-section' => 'Runtime and network',
'public-access-section' => 'Public access',
'configuration-section' => 'Configuration',
'log-delivery-section' => 'Log delivery',
'initialization-scripts-section' => 'Initialization scripts',
];
foreach ($sections as $id => $label) {
expect($sidebar)
->toContain("['id' => '{$id}', 'label' => '{$label}']")
->and($general)->toContain("id=\"{$id}\"");
}
expect($sidebar)
->toContain("\$database->type() === 'standalone-postgresql'")
->toContain('window.scrollToSettingsSection?.(id)')
->toContain("activeSection === '{{ \$section['id'] }}'")
->toContain("scrollToSection('{{ \$section['id'] }}')");
});
@@ -15,6 +15,14 @@ it('keeps server submenu state independent from the Livewire update route', func
->and($proxyLogs)->toContain('activeSubMenu="logs"');
});
it('keeps the server resources menu active during Livewire updates', function () {
$sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
expect($sidebar)
->toContain("'label' => 'Resources',\n 'route' => 'server.resources',\n 'active' => \$activeMenu === 'resources'")
->not->toContain("'active' => request()->routeIs('server.resources')");
});
it('initializes persisted sidebar state before enabling layout transitions', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
@@ -0,0 +1,7 @@
<?php
it('shows a loading indicator while creating the application', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/simple-dockerfile.blade.php'));
expect($view)->toContain('<x-forms.button type="submit" wire:target="submit" isHighlighted>');
});
+18 -1
View File
@@ -20,7 +20,7 @@ it('shows a centered themed target canvas before loading xterm', function () {
->toContain('data-terminal-target-canvas')
->toContain('items-center justify-center')
->toContain(':data-console-theme="consoleTheme"')
->toContain("@else\n <div data-terminal-session-canvas");
->toContain("@else\n <div wire:key=\"terminal-session-canvas\" data-terminal-session-canvas");
});
it('loads targets inside the themed session picker with an accent scrollbar', function () {
@@ -54,6 +54,23 @@ it('uses the same padded themed canvas for the active terminal session', functio
->toMatch('/\.terminal-session-panel\s*\{[^}]*box-shadow:\s*none;/s');
});
it('keeps pre-connection and connected terminal canvases in distinct Livewire DOM branches', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('wire:key="terminal-target-canvas"')
->toContain('wire:key="terminal-session-canvas"');
});
it('opens the global terminal outside Livewire navigation like resource terminals', function () {
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
expect($navbar)
->toContain('<a title="Terminal"')
->toContain('href="{{ route(\'terminal\') }}"')
->not->toMatch('/<a title="Terminal"[^>]*wireNavigate\(\)/s');
});
it('uses floating rounded controls instead of the legacy terminal header bar', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
+9
View File
@@ -0,0 +1,9 @@
<?php
test('deployment log timestamps omit microseconds', function () {
$helper = file_get_contents(dirname(__DIR__, 2).'/bootstrap/helpers/remoteProcess.php');
expect($helper)
->toContain("->format('Y-M-d H:i:s')")
->not->toContain("->format('Y-M-d H:i:s.u')");
});
@@ -0,0 +1,17 @@
<?php
test('sentinel startup regenerates an empty endpoint from instance settings', function () {
$action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php');
$component = file_get_contents(dirname(__DIR__, 2).'/app/Livewire/Server/Sentinel.php');
expect($action)
->toContain('ensureSentinelUrl()')
->and($component)
->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;')
->and(file_get_contents(dirname(__DIR__, 2).'/app/Models/ServerSetting.php'))
->toContain('generateSentinelUrl(ignoreEvent: true)')
->toContain('sentinelUrlFromCurrentRequest()')
->toContain('Set an instance FQDN, public IP, or reachable Coolify URL before enabling Sentinel.')
->and($component)
->toContain('$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;');
});
@@ -111,8 +111,9 @@ it('renders an automatically added redirect counterpart without reloading', func
$page = visit("{$base}/domains")
->assertSee('https://web.example.com');
$selector = '#service-domain-redirect-'.$this->serviceApplication->id;
$page->select($selector, 'www')
$selector = '#service-domain-redirect-'.$this->serviceApplication->id.'-trigger';
$page->click($selector)
->click('Redirect to www')
->wait(3);
$this->serviceApplication->refresh();