mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-21 08:25:45 +00:00
Merge remote-tracking branch 'origin/next' into celld-one-click-service
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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] }">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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:" />');
|
||||
});
|
||||
@@ -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"')
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
@@ -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,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)');
|
||||
});
|
||||
|
||||
@@ -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']");
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user