mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-24 07:25:13 -05:00
Merge remote-tracking branch 'origin/next' into coolify-analytics-traefik-caddy
This commit is contained in:
+82
-2
@@ -1914,6 +1914,61 @@ html[data-theme="custom"] textarea:disabled {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.split-action {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.split-action-main,
|
||||
.split-action-caret {
|
||||
@apply button-highlighted;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
height: 2rem;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.split-action-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0 0.625rem;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.split-action-caret {
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.split-action > .split-action-main:only-of-type {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.split-action-main:disabled,
|
||||
.split-action-caret:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.split-action-main:focus-visible,
|
||||
.split-action-caret:focus-visible {
|
||||
outline: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
.application-heading-actions .split-action-main,
|
||||
.application-heading-actions .split-action-caret {
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
/* Custom listbox (replaces native <select>) */
|
||||
.listbox-trigger {
|
||||
display: flex;
|
||||
@@ -2196,6 +2251,14 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
/* Data table (layer-card body, full-bleed) */
|
||||
.data-table {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.data-table-header {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -2557,8 +2620,25 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.service-backup-table-grid {
|
||||
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr);
|
||||
min-width: 45rem;
|
||||
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 7.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-table-row.service-backup-table-grid {
|
||||
background: var(--coollabs-base);
|
||||
border-bottom: 1px solid var(--coollabs-fill);
|
||||
}
|
||||
|
||||
.data-table-row.service-backup-table-grid:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.data-table-row.service-backup-table-grid:hover {
|
||||
background: color-mix(in srgb, var(--coollabs-base) 98%, black);
|
||||
}
|
||||
|
||||
.dark .data-table-row.service-backup-table-grid:hover {
|
||||
background: color-mix(in srgb, var(--coollabs-base) 98%, white);
|
||||
}
|
||||
|
||||
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { initializeCopyButtonComponent } from './copy-button.js';
|
||||
import { initializeTerminalComponent } from './terminal.js';
|
||||
import './traffic-globe.js';
|
||||
import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js';
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
registerLivewireRequestFailureHandler(window.Livewire);
|
||||
});
|
||||
|
||||
// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate
|
||||
// (via replaceHtmlAttributes). With `[x-cloak]{display:none}` on the app wrapper,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
export const INFRASTRUCTURE_FAILURE_STATUSES = new Set([502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]);
|
||||
|
||||
const USER_GESTURE_EVENTS = ['click', 'submit', 'keydown', 'input', 'change'];
|
||||
|
||||
// A request sent within this window of a trusted user gesture is treated as
|
||||
// user-initiated. It must cover Alpine $nextTick deferrals and wire:model
|
||||
// debounces, while staying short enough to exclude most wire:poll requests.
|
||||
export const GESTURE_WINDOW_MS = 2_000;
|
||||
|
||||
const WARN_COOLDOWN_MS = 10_000;
|
||||
const WARN_CONTENT_MAX_LENGTH = 2_000;
|
||||
|
||||
export function createLivewireRequestFailureHandler({ now = Date.now } = {}) {
|
||||
let lastWarnAt = Number.NEGATIVE_INFINITY;
|
||||
let lastToastGestureAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
return ({ status, content, preventDefault, gestureAt = Number.NEGATIVE_INFINITY }) => {
|
||||
if (!INFRASTRUCTURE_FAILURE_STATUSES.has(status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
preventDefault();
|
||||
|
||||
const currentTime = now();
|
||||
if (currentTime - lastWarnAt >= WARN_COOLDOWN_MS) {
|
||||
lastWarnAt = currentTime;
|
||||
console.warn('Livewire request failed', {
|
||||
status,
|
||||
content: typeof content === 'string' ? content.slice(0, WARN_CONTENT_MAX_LENGTH) : content,
|
||||
});
|
||||
}
|
||||
|
||||
// One toast per user gesture: a single click that fails several
|
||||
// component requests toasts once, while a retry (a new gesture)
|
||||
// always toasts again. Background requests carry no gesture.
|
||||
if (gestureAt > lastToastGestureAt) {
|
||||
lastToastGestureAt = gestureAt;
|
||||
window.toast?.('Action could not be completed', {
|
||||
type: 'danger',
|
||||
description: 'Coolify did not receive a response. Please try again.',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function registerLivewireRequestFailureHandler(Livewire, documentObject = document, { now = Date.now } = {}) {
|
||||
let lastGestureAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
const markUserGesture = (event) => {
|
||||
if (event.isTrusted) {
|
||||
lastGestureAt = now();
|
||||
}
|
||||
};
|
||||
|
||||
USER_GESTURE_EVENTS.forEach((eventName) => {
|
||||
documentObject.addEventListener(eventName, markUserGesture, true);
|
||||
});
|
||||
|
||||
const handleFailure = createLivewireRequestFailureHandler({ now });
|
||||
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
// Classify at send time: infrastructure failures (522/524) can arrive
|
||||
// long after the gesture, so the failure timestamp is meaningless.
|
||||
const gestureAt = now() - lastGestureAt <= GESTURE_WINDOW_MS ? lastGestureAt : Number.NEGATIVE_INFINITY;
|
||||
|
||||
fail((failure) => handleFailure({ ...failure, gestureAt }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
GESTURE_WINDOW_MS,
|
||||
INFRASTRUCTURE_FAILURE_STATUSES,
|
||||
createLivewireRequestFailureHandler,
|
||||
registerLivewireRequestFailureHandler,
|
||||
} from './livewire-request-failure.js';
|
||||
|
||||
function createHarness({ start = 100_000 } = {}) {
|
||||
let currentTime = start;
|
||||
let requestHook = null;
|
||||
let toasts = 0;
|
||||
let warnings = [];
|
||||
const listeners = {};
|
||||
|
||||
global.window = { toast: () => toasts++ };
|
||||
global.console = { warn: (...args) => warnings.push(args) };
|
||||
|
||||
registerLivewireRequestFailureHandler({
|
||||
hook(name, callback) {
|
||||
assert.equal(name, 'request');
|
||||
requestHook = callback;
|
||||
},
|
||||
}, {
|
||||
addEventListener(name, callback) {
|
||||
listeners[name] = callback;
|
||||
},
|
||||
}, { now: () => currentTime });
|
||||
|
||||
return {
|
||||
listeners,
|
||||
advance: (ms) => currentTime += ms,
|
||||
gesture: (event = { isTrusted: true }) => listeners.click(event),
|
||||
fail(status) {
|
||||
let prevented = false;
|
||||
let failureCallback = null;
|
||||
requestHook({ fail: (callback) => failureCallback = callback });
|
||||
failureCallback({ status, content: '<html>proxy error</html>', preventDefault: () => prevented = true });
|
||||
return prevented;
|
||||
},
|
||||
toasts: () => toasts,
|
||||
warnings: () => warnings,
|
||||
};
|
||||
}
|
||||
|
||||
test('a failure after a trusted gesture suppresses the response and shows a toast', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.gesture();
|
||||
const prevented = harness.fail(504);
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(harness.toasts(), 1);
|
||||
});
|
||||
|
||||
test('background failures are suppressed and logged without a toast', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
const prevented = harness.fail(524);
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(harness.toasts(), 0);
|
||||
assert.equal(harness.warnings().length, 1);
|
||||
assert.deepEqual(harness.warnings()[0], ['Livewire request failed', {
|
||||
status: 524,
|
||||
content: '<html>proxy error</html>',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('one gesture toasts once, but a retry gesture toasts again', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.gesture();
|
||||
harness.fail(504);
|
||||
harness.fail(504);
|
||||
assert.equal(harness.toasts(), 1);
|
||||
|
||||
harness.advance(8_000);
|
||||
harness.gesture();
|
||||
harness.fail(504);
|
||||
assert.equal(harness.toasts(), 2);
|
||||
});
|
||||
|
||||
test('requests sent outside the gesture window count as background', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.gesture();
|
||||
harness.advance(GESTURE_WINDOW_MS + 1);
|
||||
harness.fail(504);
|
||||
|
||||
assert.equal(harness.toasts(), 0);
|
||||
});
|
||||
|
||||
test('untrusted synthetic events do not count as gestures', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.gesture({ isTrusted: false });
|
||||
harness.fail(504);
|
||||
|
||||
assert.equal(harness.toasts(), 0);
|
||||
});
|
||||
|
||||
test('a missing window.toast does not throw', () => {
|
||||
const harness = createHarness();
|
||||
delete global.window.toast;
|
||||
|
||||
harness.gesture();
|
||||
assert.doesNotThrow(() => harness.fail(504));
|
||||
});
|
||||
|
||||
test('console warnings are throttled and truncated', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.fail(502);
|
||||
harness.fail(504);
|
||||
assert.equal(harness.warnings().length, 1);
|
||||
|
||||
harness.advance(10_000);
|
||||
harness.fail(504);
|
||||
assert.equal(harness.warnings().length, 2);
|
||||
|
||||
const handler = createLivewireRequestFailureHandler({ now: () => 0 });
|
||||
let logged = null;
|
||||
global.console = { warn: (message, details) => logged = details };
|
||||
handler({ status: 502, content: 'x'.repeat(5_000), preventDefault() {} });
|
||||
assert.equal(logged.content.length, 2_000);
|
||||
});
|
||||
|
||||
test('all supported infrastructure status codes are handled', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
for (const status of INFRASTRUCTURE_FAILURE_STATUSES) {
|
||||
assert.equal(harness.fail(status), true, `expected ${status} to be handled`);
|
||||
}
|
||||
});
|
||||
|
||||
test('other failures keep Livewire default handling', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.gesture();
|
||||
for (const status of [401, 419, 422, 429, 500]) {
|
||||
assert.equal(harness.fail(status), false, `expected ${status} to be untouched`);
|
||||
}
|
||||
assert.equal(harness.toasts(), 0);
|
||||
});
|
||||
@@ -267,7 +267,8 @@
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
@if ($menuItem['badge'] ?? false)
|
||||
<span class="shrink-0">
|
||||
<livewire:project.application.server-status-badge :application="$application" />
|
||||
<livewire:project.application.server-status-badge :application="$application"
|
||||
:key="'application-server-status-'.$application->uuid" />
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
@props(['application'])
|
||||
|
||||
@if ($application->stoppedAfterRestartLimit())
|
||||
@php($restartLimit = method_exists($application, 'restartLimitMaximum') ? $application->restartLimitMaximum() : ($application->max_restart_count ?? 0))
|
||||
@php($displayRestartCount = max($application->restart_count ?? 0, $restartLimit))
|
||||
<x-status-badge
|
||||
status="Restart limit reached"
|
||||
type="warning"
|
||||
title="Container has crashed and Coolify stopped it after {{ $displayRestartCount }} restart attempts." />
|
||||
@endif
|
||||
@@ -15,7 +15,7 @@
|
||||
'danger' => 'project.application.backup.danger',
|
||||
],
|
||||
'service' => [
|
||||
'back' => 'project.service.database.backups',
|
||||
'back' => 'project.service.volume-backups.index',
|
||||
'general' => 'project.service.database.backup.show',
|
||||
's3' => 'project.service.database.backup.s3',
|
||||
'retention' => 'project.service.database.backup.retention',
|
||||
@@ -48,9 +48,11 @@
|
||||
['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'],
|
||||
];
|
||||
$backLabel = $context === 'database' ? 'Back to database' : 'Back to backups';
|
||||
$backParameters = $context === 'database'
|
||||
? collect($parameters)->except('backup_uuid')->all()
|
||||
: $parameters;
|
||||
$backParameters = match ($context) {
|
||||
'database' => collect($parameters)->except('backup_uuid')->all(),
|
||||
'service' => collect($parameters)->except(['stack_service_uuid', 'backup_uuid'])->all(),
|
||||
default => $parameters,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
@props([
|
||||
'context',
|
||||
'parameters',
|
||||
'section',
|
||||
])
|
||||
|
||||
@php
|
||||
$routes = match ($context) {
|
||||
'service-schedule' => [
|
||||
'general' => true,
|
||||
's3' => true,
|
||||
'retention' => true,
|
||||
'danger' => true,
|
||||
],
|
||||
'service' => [
|
||||
'general' => 'project.service.database.backup.show',
|
||||
's3' => 'project.service.database.backup.s3',
|
||||
'retention' => 'project.service.database.backup.retention',
|
||||
'executions' => 'project.service.database.backup.executions',
|
||||
'danger' => 'project.service.database.backup.danger',
|
||||
],
|
||||
'service-volume' => [
|
||||
'general' => 'project.service.volume-backups.show',
|
||||
's3' => 'project.service.volume-backups.s3',
|
||||
'retention' => 'project.service.volume-backups.retention',
|
||||
'executions' => 'project.service.volume-backups.executions',
|
||||
'danger' => 'project.service.volume-backups.danger',
|
||||
],
|
||||
};
|
||||
|
||||
$items = collect([
|
||||
['key' => 'general', 'label' => 'General'],
|
||||
['key' => 's3', 'label' => 'S3 storage'],
|
||||
['key' => 'retention', 'label' => 'Retention'],
|
||||
['key' => 'executions', 'label' => 'Executions'],
|
||||
['key' => 'danger', 'label' => 'Danger Zone'],
|
||||
])->filter(fn (array $item): bool => isset($routes[$item['key']]));
|
||||
@endphp
|
||||
|
||||
<nav aria-label="Backup sections"
|
||||
class="flex min-w-0 flex-wrap gap-1 border-b border-neutral-200 pb-2 dark:border-white/[0.08]">
|
||||
@foreach ($items as $item)
|
||||
@if ($context === 'service-schedule')
|
||||
<button type="button" @click="activeSection = '{{ $item['key'] }}'"
|
||||
:class="activeSection === '{{ $item['key'] }}'
|
||||
? 'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25'
|
||||
: 'text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||||
class="inline-flex h-8 shrink-0 cursor-pointer items-center rounded-md px-3 text-[13px] font-medium transition-colors">
|
||||
{{ $item['label'] }}
|
||||
</button>
|
||||
@else
|
||||
<a @class([
|
||||
'inline-flex h-8 shrink-0 items-center rounded-md px-3 text-[13px] font-medium transition-colors',
|
||||
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $section === $item['key'],
|
||||
'text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg' => $section !== $item['key'],
|
||||
])
|
||||
{{ wireNavigate() }} href="{{ route($routes[$item['key']], $parameters) }}">
|
||||
{{ $item['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</nav>
|
||||
@@ -20,13 +20,32 @@
|
||||
cursorPosition: 0,
|
||||
currentScope: null,
|
||||
availableVars: @js($availableVars),
|
||||
hasVaultSource: @js($hasVaultSource),
|
||||
vaultKeysLoading: false,
|
||||
get availableScopes() {
|
||||
// Only include scopes that have at least one variable
|
||||
const allScopes = ['team', 'project', 'environment', 'server'];
|
||||
return allScopes.filter(scope => {
|
||||
const scopes = allScopes.filter(scope => {
|
||||
const vars = this.availableVars[scope];
|
||||
return vars && vars.length > 0;
|
||||
});
|
||||
// The vault scope is offered whenever a secret manager source is
|
||||
// configured; its keys are fetched lazily on first use.
|
||||
if (this.hasVaultSource) {
|
||||
scopes.push('vault');
|
||||
}
|
||||
return scopes;
|
||||
},
|
||||
loadVaultKeys() {
|
||||
if (this.vaultKeysLoading) return;
|
||||
this.vaultKeysLoading = true;
|
||||
this.$wire.fetchSecretManagerKeys().then(keys => {
|
||||
this.availableVars['vault'] = keys || [];
|
||||
this.vaultKeysLoading = false;
|
||||
this.handleInput();
|
||||
}).catch(() => {
|
||||
this.vaultKeysLoading = false;
|
||||
});
|
||||
},
|
||||
scopeUrls: @js($scopeUrls),
|
||||
|
||||
@@ -84,6 +103,15 @@
|
||||
}
|
||||
|
||||
this.currentScope = scope;
|
||||
|
||||
// Vault keys are fetched from the secret manager on first use.
|
||||
if (scope === 'vault' && this.availableVars['vault'] === undefined) {
|
||||
this.loadVaultKeys();
|
||||
this.suggestions = [];
|
||||
this.showDropdown = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const scopeVars = this.availableVars[scope] || [];
|
||||
const filtered = scopeVars.filter(v =>
|
||||
v.toLowerCase().includes((partial || '').toLowerCase())
|
||||
@@ -214,6 +242,7 @@
|
||||
wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
|
||||
@endif
|
||||
wire:loading.attr="disabled"
|
||||
wire:target.except="fetchSecretManagerKeys"
|
||||
@disabled($disabled)
|
||||
@if ($type !== 'password')
|
||||
type="{{ $type }}"
|
||||
@@ -236,7 +265,14 @@
|
||||
<div x-show="showDropdown" x-cloak x-transition.origin.top
|
||||
class="listbox-panel top-full! z-[60]! mt-1! w-full! min-w-0! max-w-full!" role="listbox">
|
||||
|
||||
<template x-if="suggestions.length === 0 && currentScope">
|
||||
<template x-if="suggestions.length === 0 && currentScope === 'vault'">
|
||||
<div class="px-2 py-2 text-sm text-neutral-500 dark:text-fg-dim">
|
||||
<span x-show="vaultKeysLoading">Loading keys from the secret manager…</span>
|
||||
<span x-show="!vaultKeysLoading">No matching keys in the secret manager.</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="suggestions.length === 0 && currentScope && currentScope !== 'vault'">
|
||||
<div class="px-2 py-2 text-sm text-neutral-500 dark:text-fg-dim">
|
||||
<div>No shared variables found in <span class="font-semibold" x-text="currentScope"></span> scope.</div>
|
||||
<a :href="getScopeUrl(currentScope)"
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($type === 'password')
|
||||
@if ($loading)
|
||||
<div class="{{ $defaultClass }} flex w-full items-center text-neutral-500 dark:text-fg-dim" aria-busy="true">
|
||||
<x-loading :text="$loadingText" />
|
||||
</div>
|
||||
@elseif ($type === 'password')
|
||||
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
|
||||
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
|
||||
x-bind:type="type"
|
||||
@@ -33,7 +37,7 @@
|
||||
@readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
|
||||
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
|
||||
aria-placeholder="{{ $attributes->get('placeholder') }}"
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif>
|
||||
@if ($autofocus) x-ref="autofocusInput" autofocus @endif>
|
||||
@if ($allowToPeak)
|
||||
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
|
||||
class="password-toggle flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 hover:text-black dark:text-neutral-400 dark:hover:text-white"
|
||||
@@ -56,7 +60,7 @@
|
||||
maxlength="{{ $attributes->get('maxlength') }}"
|
||||
@if ($htmlId !== 'null') id={{ $htmlId }} @endif name="{{ $name }}"
|
||||
placeholder="{{ $attributes->get('placeholder') }}"
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif>
|
||||
@if ($autofocus) x-ref="autofocusInput" autofocus @endif>
|
||||
@endif
|
||||
@if (!$label && $helper)
|
||||
<x-helper :helper="$helper" />
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
monacoVersion: '0.52.2',
|
||||
monacoContent: @entangle($id),
|
||||
monacoLanguage: '',
|
||||
monacoPlaceholder: true,
|
||||
monacoPlaceholderText: 'Start typing here',
|
||||
monacoLoader: true,
|
||||
monacoFontSize: '15px',
|
||||
monacoId: $id('monaco-editor'),
|
||||
@@ -14,20 +12,7 @@
|
||||
monacoEditor(editor) {
|
||||
editor.onDidChangeModelContent((e) => {
|
||||
this.monacoContent = editor.getValue();
|
||||
this.updatePlaceholder(editor.getValue());
|
||||
});
|
||||
editor.onDidBlurEditorWidget(() => {
|
||||
this.updatePlaceholder(editor.getValue());
|
||||
});
|
||||
editor.onDidFocusEditorWidget(() => {
|
||||
this.updatePlaceholder(editor.getValue());
|
||||
});
|
||||
},
|
||||
updatePlaceholder(value) {
|
||||
this.monacoPlaceholder = value === '';
|
||||
},
|
||||
monacoEditorFocus() {
|
||||
document.getElementById(this.monacoId).dispatchEvent(new CustomEvent('monaco-editor-focused', { detail: { monacoId: this.monacoId } }));
|
||||
},
|
||||
monacoEditorAddLoaderScriptToHead() {
|
||||
// Use a global flag to prevent duplicate script loading
|
||||
@@ -111,6 +96,7 @@
|
||||
lineNumbersMinChars: 3,
|
||||
automaticLayout: true,
|
||||
language: '{{ $language }}',
|
||||
placeholder: 'Start typing here',
|
||||
domReadOnly: '{{ $readonly ?? false }}',
|
||||
contextmenu: '!{{ $readonly ?? false }}',
|
||||
renderLineHighlight: 'none',
|
||||
@@ -143,13 +129,8 @@
|
||||
});
|
||||
|
||||
monacoEditor(editor);
|
||||
|
||||
document.getElementById(monacoId).editor = editor;
|
||||
document.getElementById(monacoId).addEventListener('monaco-editor-focused', (event) => {
|
||||
editor.focus();
|
||||
});
|
||||
|
||||
updatePlaceholder(editor.getValue());
|
||||
document.getElementById(monacoId).editor = editor;
|
||||
|
||||
@if ($autofocus)
|
||||
// Auto-focus the editor
|
||||
@@ -172,10 +153,6 @@
|
||||
</div>
|
||||
<div class="relative z-10 w-full h-full">
|
||||
<div x-ref="monacoEditorElement" class="w-full text-md {{ $readonly ? 'opacity-65' : '' }}" style="height: var(--editor-height, calc(100vh - 20rem)); min-height: 150px;"></div>
|
||||
<div x-ref="monacoPlaceholderElement" x-show="monacoPlaceholder" @click="monacoEditorFocus()"
|
||||
:style="'font-size: ' + monacoFontSize"
|
||||
class="w-full text-sm font-mono absolute z-50 text-gray-500 ml-14 -translate-x-0.5 mt-0.5 left-0 top-0"
|
||||
x-text="monacoPlaceholderText"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]"></div>
|
||||
<div @if ($closeOutside) @click.self="modalOpen=false" @endif class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
|
||||
<div @if ($closeOutside) @click.self="modalOpen=false" @endif class="relative flex min-h-full items-start justify-center p-2 sm:items-center sm:p-4">
|
||||
<div id="{{ $modalId }}" x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
@@ -61,19 +61,22 @@
|
||||
'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!">
|
||||
<header class="flex-wrap! sm:flex-nowrap!">
|
||||
<h3 class="min-w-0 flex-1 truncate">{{ $title }}</h3>
|
||||
@isset($headerActions)
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<div class="order-3 w-full sm:order-none sm:w-auto 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">
|
||||
class="order-2 sm:order-none 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" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body min-h-0 flex-1 overflow-y-auto"
|
||||
<div @class([
|
||||
'application-settings-section-body min-h-0 flex-1 overflow-y-auto',
|
||||
'mt-2 sm:mt-0' => isset($headerActions),
|
||||
])
|
||||
style="-webkit-overflow-scrolling: touch;">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,17 @@
|
||||
'Deployments' => [
|
||||
['key' => 'deploymentSuccess', 'label' => 'Deployment success'],
|
||||
['key' => 'deploymentFailure', 'label' => 'Deployment failure'],
|
||||
],
|
||||
'Resources' => [
|
||||
[
|
||||
'key' => 'statusChange',
|
||||
'label' => 'Container status changes',
|
||||
'helper' => 'Notify when a container stops or restarts.',
|
||||
'label' => 'Resource status changes',
|
||||
'helper' => 'Notify when a resource stops or Coolify automatically restarts it.',
|
||||
],
|
||||
[
|
||||
'key' => 'restartLimitReached',
|
||||
'label' => 'Restart limit reached',
|
||||
'helper' => 'Notify when a resource is stopped after reaching its restart limit.',
|
||||
],
|
||||
],
|
||||
'Backups' => [
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
'livewire' => false,
|
||||
'options' => [10, 25, 50, 100],
|
||||
'storageKey' => null,
|
||||
'canGate' => null,
|
||||
'canResource' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$disabled = $canGate && $canResource
|
||||
&& ! Illuminate\Support\Facades\Gate::allows($canGate, $canResource);
|
||||
@endphp
|
||||
|
||||
<div class="mb-0! flex h-7 items-center gap-1.5 text-[11px] text-neutral-500 dark:text-fg-dim"
|
||||
x-data="{
|
||||
selectedPageSize: @js((int) $options[0]),
|
||||
@@ -36,6 +43,7 @@
|
||||
<x-table.dropdown panel-class="min-w-24!">
|
||||
<x-slot:trigger>
|
||||
<button type="button" aria-label="Items per page" aria-haspopup="listbox" :aria-expanded="open"
|
||||
@disabled($disabled)
|
||||
class="inline-flex h-7! w-12! items-center justify-between border-0 px-1 text-[11px]! leading-none! tabular-nums text-neutral-500 transition-colors hover:text-black dark:text-fg-dim dark:hover:text-fg">
|
||||
<span x-text="selectedPageSize"></span>
|
||||
<x-reicon name="chevron-down" class="size-3 text-neutral-400 dark:text-fg-faint" />
|
||||
@@ -43,6 +51,7 @@
|
||||
</x-slot:trigger>
|
||||
@foreach ($options as $option)
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
@disabled($disabled)
|
||||
:aria-selected="selectedPageSize === {{ (int) $option }}"
|
||||
x-on:click="applyPageSize({{ (int) $option }})">
|
||||
<span>{{ $option }}</span>
|
||||
@@ -50,6 +59,7 @@
|
||||
</button>
|
||||
@endforeach
|
||||
<button type="button" class="listbox-option" role="option"
|
||||
@disabled($disabled)
|
||||
x-on:click="customizingPageSize = true; $nextTick(() => $refs.customPageSize.focus())">
|
||||
Custom…
|
||||
</button>
|
||||
@@ -58,6 +68,6 @@
|
||||
<input x-cloak x-show="customizingPageSize" x-ref="customPageSize" x-model.number="customPageSize"
|
||||
x-on:keydown.enter.prevent="applyPageSize(customPageSize)" x-on:keydown.escape.prevent="customizingPageSize = false"
|
||||
x-on:blur="if (customizingPageSize) applyPageSize(customPageSize)" type="number" min="1" max="100" inputmode="numeric"
|
||||
aria-label="Custom items per page"
|
||||
aria-label="Custom items per page" @disabled($disabled)
|
||||
class="mb-0! h-7! w-14! rounded-md! border-neutral-200! bg-transparent! px-1.5! py-0! text-[11px]! tabular-nums shadow-none! focus:border-neutral-300! focus:ring-0! dark:border-white/[0.08]! dark:text-fg-dim!" />
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,16 @@
|
||||
|
||||
@php
|
||||
$serverReady = $server->isFunctional();
|
||||
$proxyUpdateAvailable = $server->proxySet()
|
||||
&& ($server->hasCurrentTraefikOutdatedInfo() || $server->hasPendingProxyConfiguration());
|
||||
$proxyConfigurationPending = $server->proxySet() && $server->hasPendingProxyConfiguration();
|
||||
$traefikUpdateAvailable = $server->proxySet() && $server->hasCurrentTraefikOutdatedInfo();
|
||||
$proxyUpdateAvailable = $proxyConfigurationPending || $traefikUpdateAvailable;
|
||||
$proxyNeedsAttention = $server->proxySet()
|
||||
&& (! in_array($proxyStatus, ['running'], true) || $proxyUpdateAvailable);
|
||||
$proxyStatusLabel = match (true) {
|
||||
$proxyConfigurationPending => 'Restart required',
|
||||
$traefikUpdateAvailable => 'Update available',
|
||||
default => str($proxyStatus ?: 'unknown')->headline(),
|
||||
};
|
||||
$sentinelNeedsAttention = $showSentinelStatus && ! $server->isSentinelLive();
|
||||
|
||||
[$summaryLabel, $summaryType] = match (true) {
|
||||
@@ -66,7 +72,7 @@
|
||||
'bg-error' => $proxyNeedsAttention && ! $proxyUpdateAvailable && ! in_array($proxyStatus, ['starting', 'restarting', 'stopping'], true),
|
||||
])></span>
|
||||
<span class="flex-1">Proxy</span>
|
||||
<span>{{ str($proxyStatus ?: 'unknown')->headline() }}</span>
|
||||
<span>{{ $proxyStatusLabel }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@if ($showSentinelStatus)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
@props([
|
||||
'parameters',
|
||||
'serviceDatabase',
|
||||
'isImportSupported' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$serviceParameters = \Illuminate\Support\Arr::except($parameters, ['stack_service_uuid']);
|
||||
$items = [
|
||||
[
|
||||
'label' => 'General',
|
||||
@@ -19,22 +17,6 @@
|
||||
'icon' => 'grid',
|
||||
'active' => request()->routeIs('project.service.index.advanced'),
|
||||
],
|
||||
[
|
||||
'label' => 'Backups',
|
||||
'route' => 'project.service.volume-backups.index',
|
||||
'parameters' => $serviceParameters,
|
||||
'icon' => 'storages',
|
||||
'active' => request()->routeIs('project.service.database.backup*'),
|
||||
'visible' => $serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated,
|
||||
],
|
||||
[
|
||||
'label' => 'Import Backup',
|
||||
'route' => 'project.service.database.import',
|
||||
'icon' => 'upload',
|
||||
'active' => request()->routeIs('project.service.database.import'),
|
||||
'visible' => $isImportSupported,
|
||||
'navigate' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$items = array_values(array_filter($items, fn (array $item): bool => $item['visible'] ?? true));
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
|
||||
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
|
||||
['label' => 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false],
|
||||
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
|
||||
@@ -27,13 +28,15 @@
|
||||
|| ($item['route'] === 'project.service.scheduled-tasks.show'
|
||||
&& str($currentRoute)->startsWith('project.service.scheduled-tasks'))
|
||||
|| ($item['route'] === 'project.service.volume-backups.index'
|
||||
&& str($currentRoute)->startsWith('project.service.volume-backups')),
|
||||
&& str($currentRoute)->startsWith('project.service.volume-backups'))
|
||||
|| ($item['route'] === 'project.service.import-backup'
|
||||
&& str($currentRoute)->startsWith('project.service.import-backup')),
|
||||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
|
||||
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'],
|
||||
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<div {{ $attributes->class(['split-action relative']) }} x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })" @click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<button type="button" {{ $main->attributes->class(['split-action-main']) }}>
|
||||
{{ $main }}
|
||||
</button>
|
||||
@if ($slot->hasActualContent())
|
||||
<button type="button" class="split-action-caret" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu" aria-label="More actions">
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
<div x-cloak x-show="open" x-transition.origin.top
|
||||
class="listbox-panel right-0! xl:left-auto! xl:min-w-60! xl:max-w-96!" role="menu">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
$containerType = match (true) {
|
||||
str($containerStatus)->startsWith('running') => 'success',
|
||||
str($containerStatus)->startsWith(['starting', 'restarting']) => 'warning',
|
||||
str($containerStatus)->startsWith(['starting', 'restarting', 'degraded']) => 'warning',
|
||||
default => 'error',
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
[$summaryLabel, $summaryType] = match (true) {
|
||||
$containerType === 'error' => [$containerLabel, 'error'],
|
||||
str($containerStatus)->startsWith('degraded') => ['Degraded', 'warning'],
|
||||
$healthType === 'error' => ['Degraded', 'error'],
|
||||
$containerType === 'warning' => [$containerLabel, 'warning'],
|
||||
$monitoringExcluded => ["{$containerLabel} (monitoring disabled)", 'warning'],
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
title="Container has restarted {{ $resource->restart_count }} time{{ $resource->restart_count > 1 ? 's' : '' }}. Last restart: {{ $resource->last_restart_at?->diffForHumans() }}" />
|
||||
@endif
|
||||
@if ($stoppedAfterRestartLimit)
|
||||
<x-status-badge status="Stopped after reaching restart limit ({{ $resource->restart_count }}/{{ $resource->max_restart_count }})."
|
||||
type="warning"
|
||||
title="Container has crashed and Coolify stopped it after {{ $resource->restart_count }} restart attempts." />
|
||||
<x-application.restart-limit-warning :application="$resource" />
|
||||
@endif
|
||||
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
|
||||
<x-status-badge as="button" wire:target="manualCheckStatus" wire:loading.attr="disabled"
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
'active' => request()->routeIs('team.member.index'),
|
||||
'icon' => 'teams',
|
||||
],
|
||||
auth()->user()->isAdminOfTeam(currentTeam()->id) ? [
|
||||
'label' => 'Audit log',
|
||||
'route' => 'team.audit-log',
|
||||
'active' => request()->routeIs('team.audit-log'),
|
||||
'icon' => 'time-back',
|
||||
] : null,
|
||||
isInstanceAdmin() ? [
|
||||
'label' => 'Admin View',
|
||||
'route' => 'team.admin-view',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
pageWidth: localStorage.getItem('pageWidth') || 'full',
|
||||
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
|
||||
themeColorFrame: null,
|
||||
avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null),
|
||||
avatarUrl: @js($user?->avatar_path ? profile_avatar_url($user) : null),
|
||||
openPanel() {
|
||||
this.appearanceOpen = false;
|
||||
this.open = true;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<x-emails.layout>
|
||||
A resource ({{ $containerName }}) has been stopped unexpectedly on {{ $serverName }}.
|
||||
|
||||
@if ($url)
|
||||
Please check what is going on [here]({{ $url }}).
|
||||
@endif
|
||||
</x-emails.layout>
|
||||
@@ -1,5 +1,8 @@
|
||||
@extends('layouts.base')
|
||||
@section('body')
|
||||
{{-- Manual @livewireScripts disables Livewire's asset auto-injection, so the
|
||||
styles (e.g. [wire\:loading] { display: none }) must be rendered manually too. --}}
|
||||
@livewireStyles
|
||||
@livewireScripts
|
||||
<main class="h-full bg-gray-50 dark:bg-base">
|
||||
{{ $slot }}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<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.04] dark:text-fg-dim">
|
||||
@if ($project->icon_path)
|
||||
<img src="{{ route('project.icon', ['project_uuid' => $project->uuid, 'v' => $project->updated_at->timestamp]) }}"
|
||||
<img src="{{ project_icon_url($project) }}"
|
||||
alt="{{ $project->name }} icon"
|
||||
class="h-full w-full rounded-lg object-cover">
|
||||
@else
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="mx-auto flex min-h-screen w-full max-w-3xl flex-col gap-6 px-6 py-12">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-coollabs">Development tool</p>
|
||||
<h1 class="text-2xl font-semibold text-neutral-950 dark:text-white">Livewire request failure preview</h1>
|
||||
<p class="text-sm leading-6 text-neutral-600 dark:text-fg-dim">
|
||||
Each button returns proxy-style HTML from a failed Livewire request. The page should remain visible and
|
||||
Coolify should show a toast instead of Livewire's raw response modal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
@foreach ($statuses as $status)
|
||||
<x-forms.button wire:click="fail({{ $status }})" class="justify-between">
|
||||
<span>{{ in_array($status, [504, 522, 524], true) ? 'Gateway timeout' : 'Proxy unavailable' }}</span>
|
||||
<span class="font-mono text-xs text-neutral-500 dark:text-fg-faint">{{ $status }}</span>
|
||||
</x-forms.button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-neutral-500 dark:text-fg-faint">
|
||||
This route is registered only when <code class="font-mono">APP_ENV</code> is local or testing.
|
||||
</p>
|
||||
</div>
|
||||
@@ -655,7 +655,9 @@
|
||||
class="search-result-item command-palette-item">
|
||||
<template x-if="item.logo">
|
||||
<div class="command-palette-item-icon">
|
||||
<img :src="'/' + item.logo" :alt="item.name">
|
||||
<img :src="item.logo.startsWith('http') ? item.logo : '/' + item.logo"
|
||||
:alt="item.name"
|
||||
x-on:error="if (item.logo_cdn_url && !$el.dataset.cdnTried) { $el.dataset.cdnTried = 'true'; $el.src = item.logo_cdn_url; } else if (item.logo_default_url && !$el.dataset.defaultTried) { $el.dataset.defaultTried = 'true'; $el.src = item.logo_default_url; }">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!item.logo">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
reminderCollapseAfter: 10000,
|
||||
isDevelopment: {{ isDev() ? 'true' : 'false' }},
|
||||
init() {
|
||||
this.popups.sponsorship = this.shouldShowMonthlyPopup('popupSponsorship');
|
||||
this.popups.sponsorship = !this.isDevelopment && this.shouldShowMonthlyPopup('popupSponsorship');
|
||||
this.popups.notification = this.shouldShowMonthlyPopup('popupNotification');
|
||||
this.popups.realtime = localStorage.getItem('popupRealtime');
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
return isDifferentMonth;
|
||||
{{-- } --}}
|
||||
}
|
||||
}">
|
||||
}" x-on:show-sponsorship-reminder.window="popups.sponsorship = true; reminders.sponsorship.compact = false">
|
||||
@auth
|
||||
<span x-show="popups.realtime === true">
|
||||
@if (!isCloud())
|
||||
@@ -140,11 +140,12 @@
|
||||
@endif
|
||||
</span>
|
||||
@endauth
|
||||
@if (instanceSettings()->is_sponsorship_popup_enabled && !isCloud())
|
||||
@if ((isDev() || instanceSettings()->is_sponsorship_popup_enabled) && ! isCloud())
|
||||
<span x-show="popups.sponsorship">
|
||||
<x-popup>
|
||||
<x-slot:customActions>
|
||||
<div class="relative mx-auto flex w-full flex-col overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-modal transition-all duration-300 dark:border-white/[0.1] dark:bg-surface"
|
||||
x-on:show-sponsorship-reminder.window="bannerVisible = true"
|
||||
:class="reminders.sponsorship.compact ? 'max-w-sm gap-3 p-4' : 'max-w-2xl gap-5 p-5 sm:p-6'">
|
||||
<button type="button" aria-label="Dismiss sponsorship reminder"
|
||||
class="absolute top-3 right-3 flex size-7 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||||
|
||||
@@ -149,7 +149,11 @@
|
||||
:events="[
|
||||
['property' => 'deploymentSuccessEmailNotifications', 'label' => 'Deployment success', 'enabled' => $deploymentSuccessEmailNotifications],
|
||||
['property' => 'deploymentFailureEmailNotifications', 'label' => 'Deployment failure', 'enabled' => $deploymentFailureEmailNotifications],
|
||||
['property' => 'statusChangeEmailNotifications', 'label' => 'Container status changes', 'enabled' => $statusChangeEmailNotifications],
|
||||
]" />
|
||||
<x-notification.event-multiselect :settings="$settings" id="resource-email-events" label="Resources"
|
||||
:events="[
|
||||
['property' => 'statusChangeEmailNotifications', 'label' => 'Resource status changes', 'enabled' => $statusChangeEmailNotifications],
|
||||
['property' => 'restartLimitReachedEmailNotifications', 'label' => 'Restart limit reached', 'enabled' => $restartLimitReachedEmailNotifications],
|
||||
]" />
|
||||
<x-notification.event-multiselect :settings="$settings" id="backup-email-events" label="Backups"
|
||||
:events="[
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
<img x-cloak x-show="preview" :src="preview" alt="Profile picture preview"
|
||||
class="h-full w-full object-cover">
|
||||
@if (auth()->user()->avatar_path)
|
||||
<img src="{{ route('profile.avatar', ['v' => auth()->user()->updated_at->timestamp]) }}"
|
||||
<img src="{{ profile_avatar_url(auth()->user()) }}"
|
||||
x-show="!preview" alt="{{ auth()->user()->name }}" class="h-full w-full object-cover">
|
||||
@else
|
||||
<span x-show="!preview">
|
||||
@@ -249,9 +249,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<form action="/user/confirmed-two-factor-authentication" method="POST"
|
||||
class="flex items-end gap-2">
|
||||
class="flex items-end gap-2"
|
||||
x-init="$nextTick(() => $el.querySelector('input[name=code]')?.focus())">
|
||||
@csrf
|
||||
<x-forms.input type="text" inputmode="numeric" pattern="[0-9]*" id="code"
|
||||
<x-forms.input name="code" type="text" inputmode="numeric" pattern="[0-9]*" id="code"
|
||||
label="One-time code" required />
|
||||
<x-forms.button type="submit">Validate 2FA</x-forms.button>
|
||||
</form>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<livewire:project.application.advanced :application="$application" />
|
||||
@elseif ($currentRoute === 'project.application.environment-variables')
|
||||
<livewire:project.shared.environment-variable.all :resource="$application" />
|
||||
<livewire:project.shared.secret-manager-links :resource="$application" />
|
||||
@elseif ($currentRoute === 'project.application.persistent-storage')
|
||||
<livewire:project.service.storage :resource="$application" />
|
||||
@elseif ($currentRoute === 'project.application.source' && $application->git_based())
|
||||
|
||||
@@ -213,39 +213,43 @@
|
||||
$commitMessage = $deployment->commitMessage()
|
||||
? Str::before($deployment->commitMessage(), "\n")
|
||||
: null;
|
||||
$deploymentUrl = $current_url . '/' . data_get($deployment, 'deployment_uuid');
|
||||
$commitUrl = data_get($deployment, 'commit')
|
||||
? $application->gitCommitLink(data_get($deployment, 'commit'))
|
||||
: null;
|
||||
@endphp
|
||||
<a wire:key="deployment-{{ data_get($deployment, 'deployment_uuid') }}"
|
||||
href="{{ $current_url . '/' . data_get($deployment, 'deployment_uuid') }}"
|
||||
{{ wireNavigate() }}
|
||||
<div wire:key="deployment-{{ data_get($deployment, 'deployment_uuid') }}"
|
||||
@class([
|
||||
'data-table-row deployment-table-grid border-b border-neutral-200 text-[13px] text-neutral-600 dark:border-white/[0.08] dark:text-fg-dim',
|
||||
'data-table-row-active' => $selectedDeploymentUuid === data_get($deployment, 'deployment_uuid'),
|
||||
])>
|
||||
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
|
||||
<span>{{ $sourceLabel }}</span>
|
||||
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }}><x-status-badge :status="$statusLabel" :type="$statusType" /></a>
|
||||
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }}>{{ $sourceLabel }}</a>
|
||||
<span class="min-w-0">
|
||||
@if (data_get($deployment, 'commit'))
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="shrink-0 font-mono text-xs text-neutral-950 dark:text-fg">
|
||||
<a href="{{ $commitUrl }}" target="_blank" rel="noopener noreferrer"
|
||||
class="shrink-0 font-mono text-xs text-neutral-950 underline decoration-neutral-400 underline-offset-2 dark:text-fg dark:decoration-neutral-600">
|
||||
{{ substr(data_get($deployment, 'commit'), 0, 7) }}
|
||||
</span>
|
||||
</a>
|
||||
@if ($commitMessage)
|
||||
<span class="truncate text-neutral-500 dark:text-fg-faint"
|
||||
title="{{ $commitMessage }}">{{ $commitMessage }}</span>
|
||||
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }}
|
||||
class="truncate text-neutral-500 dark:text-fg-faint"
|
||||
title="{{ $commitMessage }}">{{ $commitMessage }}</a>
|
||||
@endif
|
||||
</span>
|
||||
@else
|
||||
<span class="text-neutral-400 dark:text-fg-faint">-</span>
|
||||
@endif
|
||||
</span>
|
||||
<span title="{{ formatDateInServerTimezone(data_get($deployment, 'created_at'), data_get($application, 'destination.server')) }}">
|
||||
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }} title="{{ formatDateInServerTimezone(data_get($deployment, 'created_at'), data_get($application, 'destination.server')) }}">
|
||||
{{ \Carbon\Carbon::parse(data_get($deployment, 'created_at'))->diffForHumans() }}
|
||||
</span>
|
||||
<span class="tabular-nums">{{ $duration }}</span>
|
||||
<span class="truncate">
|
||||
</a>
|
||||
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }} class="tabular-nums">{{ $duration }}</a>
|
||||
<a href="{{ $deploymentUrl }}" {{ wireNavigate() }} class="truncate">
|
||||
{{ data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-') }}
|
||||
</span>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
$configuredCount = collect($domainRows)->where('is_suggested', false)->count();
|
||||
$suggestedCount = collect($domainRows)->where('is_suggested', true)->count();
|
||||
$hasRows = count($domainRows) > 0;
|
||||
$hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking');
|
||||
$composeDomainGroups = collect($domainRows)
|
||||
->groupBy(fn ($row) => $row['service'] ?? '__unknown')
|
||||
->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false)));
|
||||
@@ -36,6 +37,9 @@
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain()"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
@if ($hasDnsChecksInProgress)
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
@endif
|
||||
<x-application.settings-section id="domains-section" title="Domains">
|
||||
@can('update', $application)
|
||||
<x-slot:actions>
|
||||
@@ -362,4 +366,48 @@
|
||||
|
||||
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
|
||||
confirmAction="confirmDomainUsage" />
|
||||
|
||||
@if ($showPortWarningModal)
|
||||
<div x-data="{ modalOpen: true }"
|
||||
@keydown.escape.window="modalOpen = false; $wire.call('cancelUseUnknownPort')"
|
||||
class="relative z-40">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
||||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Use a different port?</h3>
|
||||
<button type="button"
|
||||
@click="modalOpen = false; $wire.call('cancelUseUnknownPort')"
|
||||
class="icon-button" aria-label="Close">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Unrecognized internal port" class="mb-4">
|
||||
Port <strong>{{ $unrecognizedPort }}</strong> is not listed in Ports Exposes
|
||||
and is not used by any application domain. The proxy will still route to it,
|
||||
but the container may not be listening there.
|
||||
</x-callout>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button type="button" canGate="update" :canResource="$application"
|
||||
@click="modalOpen = false; $wire.call('cancelUseUnknownPort')">
|
||||
Cancel
|
||||
</x-forms.button>
|
||||
<x-forms.button type="button" wire:click="confirmUseUnknownPort" canGate="update"
|
||||
:canResource="$application"
|
||||
@click="modalOpen = false" isError>
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -122,8 +122,7 @@
|
||||
@else
|
||||
<div class="flex flex-col gap-5">
|
||||
@if ($buildPack === 'dockercompose')
|
||||
<div class="flex flex-col gap-2"
|
||||
@can('update', $application) x-init="$wire.dispatch('loadCompose', true)" @endcan>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div x-data="{
|
||||
baseDir: @entangle('baseDirectory'),
|
||||
composeLocation: @entangle('dockerComposeLocation'),
|
||||
@@ -402,7 +401,15 @@
|
||||
@endif
|
||||
|
||||
@if ($buildPack !== 'dockercompose')
|
||||
<x-application.settings-section id="networking-section" title="Networking" helper="Ports the container exposes, host port mappings and internal network aliases.">
|
||||
@php
|
||||
$applicationDomainsUrl = route('project.application.domains', [
|
||||
'project_uuid' => $application->environment->project->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
]);
|
||||
$portsExposesDomainHint = "You can also set a different internal port for each domain on the <a class='underline dark:text-white' href='{$applicationDomainsUrl}'>Domains</a> page.";
|
||||
@endphp
|
||||
<x-application.settings-section id="networking-section" title="Networking" helper="Ports the container exposes, host port mappings and internal network aliases. You can also set an internal port per domain.">
|
||||
@if ($this->detectedPortInfo)
|
||||
@if ($this->detectedPortInfo['isEmpty'])
|
||||
<div
|
||||
@@ -464,20 +471,30 @@
|
||||
</x-callout>
|
||||
@endif
|
||||
<div class="grid gap-4 lg:grid-cols-[14rem_16rem_minmax(0,1fr)]">
|
||||
<div class="min-w-0">
|
||||
@if ($isStatic || $buildPack === 'static')
|
||||
<x-forms.input id="portsExposes" label="Ports exposes" readonly
|
||||
:helper="$portsExposesDomainHint"
|
||||
canGate="update" :canResource="$application"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@else
|
||||
@if ($application->settings->is_container_label_readonly_enabled === false)
|
||||
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports exposes" readonly
|
||||
helper="Readonly labels are disabled. You can set the ports manually in the labels section."
|
||||
:helper="'Readonly labels are disabled. You can set the ports manually in the labels section.<br><br>'.$portsExposesDomainHint"
|
||||
canGate="update" :canResource="$application"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@else
|
||||
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports exposes"
|
||||
helper="A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly."
|
||||
:helper="'A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly.<br><br>'.$portsExposesDomainHint"
|
||||
canGate="update" :canResource="$application"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@endif
|
||||
@endif
|
||||
<p class="mt-1.5 text-xs text-neutral-500 dark:text-fg-dim">
|
||||
You can also set an internal port per domain on
|
||||
<a class="underline dark:text-white" href="{{ $applicationDomainsUrl }}" {{ wireNavigate() }}>Domains</a>.
|
||||
</p>
|
||||
</div>
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
<x-forms.input placeholder="3000:3000" id="portsMappings" label="Port mappings"
|
||||
helper="A comma separated list of ports you would like to map to the host system. Useful when you do not want to use domains.<br><br><span class='inline-block font-bold dark:text-warning'>Format:</span> host:container<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span> 3000:3000,3002:3002<br><br>Rolling update is not supported if you have a port mapped to the host."
|
||||
|
||||
@@ -40,123 +40,79 @@
|
||||
<div class="w-full xl:hidden">
|
||||
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
|
||||
@can('deploy', $application)
|
||||
<div id="application-mobile-actions" class="relative mb-3"
|
||||
x-data="{ open: false }" @click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<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">
|
||||
Actions
|
||||
</span>
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.left
|
||||
class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="menu">
|
||||
@if (!str($application->status)->startsWith('exited'))
|
||||
<x-split-action id="application-mobile-actions" class="mb-3 flex w-full">
|
||||
@if (str($application->status)->startsWith('exited'))
|
||||
<x-slot:main wire:click="deploy">
|
||||
<x-reicon name="play-circle" class="size-3.5" />
|
||||
{{ $application->stoppedAfterRestartLimit() ? 'Retry deployment' : 'Deploy' }}
|
||||
</x-slot:main>
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@endcan
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" wire:click="deploy(true)"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
{{ $application->stoppedAfterRestartLimit() ? 'Retry deployment (without cache)' : 'Deploy (without cache)' }}
|
||||
</button>
|
||||
@endif
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
@if ($application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Update Service
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Update Service
|
||||
</button>
|
||||
@endcan
|
||||
@if ($application->container_present !== false)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()" role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Remove container
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
@if ($application->destination->server->isSwarm())
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
<x-slot:main wire:click="deploy">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Update Service
|
||||
</x-slot:main>
|
||||
@else
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@endcan
|
||||
<x-slot:main @click="document.getElementById('application-mobile-stop-trigger')?.click()">
|
||||
<x-reicon name="stop-circle" class="size-3.5" />
|
||||
Stop
|
||||
</x-slot:main>
|
||||
@endif
|
||||
@else
|
||||
<x-slot:main wire:click="deploy">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Redeploy
|
||||
</x-slot:main>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="{{ str($application->status)->startsWith('running') ? 'force_deploy_without_cache' : 'deploy(true)' }}"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
{{ str($application->status)->startsWith('running') ? 'Redeploy (without cache)' : 'Deploy (without cache)' }}
|
||||
</button>
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
@can('deploy', $application)
|
||||
@unless ($application->destination->server->isSwarm() && $application->build_pack === 'dockercompose')
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
@else
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@endcan
|
||||
@endunless
|
||||
@endif
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="{{ $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)' }}"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Deploy (without cache)
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Deploy (without cache)
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-split-action>
|
||||
@endcan
|
||||
@endif
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
|
||||
<x-modal-confirmation
|
||||
canGate="deploy" :canResource="$application"
|
||||
title="{{ str($application->status)->startsWith('exited') ? 'Confirm Container Removal?' : 'Confirm Application Stopping?' }}"
|
||||
buttonTitle="{{ str($application->status)->startsWith('exited') ? 'Remove container' : 'Stop' }}"
|
||||
submitAction="stop" :checkboxes="$checkboxes" :actions="[
|
||||
'This application will be stopped.',
|
||||
'All non-persistent data of this application will be deleted.',
|
||||
str($application->status)->startsWith('exited') ? 'The exited application container will be removed.' : 'This application will be stopped.',
|
||||
str($application->status)->startsWith('exited') ? 'Anonymous volumes may become eligible for Docker cleanup.' : 'All non-persistent data of this application will be deleted.',
|
||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||
step1ButtonText="Continue" step2ButtonText="Confirm">
|
||||
<x-slot:trigger>
|
||||
@@ -188,100 +144,69 @@
|
||||
<x-applications.links :application="$application" />
|
||||
</div>
|
||||
@can('deploy', $application)
|
||||
<div id="application-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button button-highlighted" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu">
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
<x-split-action id="application-desktop-actions">
|
||||
@if (str($application->status)->startsWith('exited'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $application))
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
<x-slot:main wire:click="deploy">
|
||||
<x-reicon name="play-circle" class="size-3.5" />
|
||||
{{ $application->stoppedAfterRestartLimit() ? 'Retry deployment' : 'Deploy' }}
|
||||
</x-slot:main>
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $application))
|
||||
wire:click="deploy(true)" @click="open = false" role="menuitem">
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" wire:click="deploy(true)"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Deploy (without cache)
|
||||
{{ $application->stoppedAfterRestartLimit() ? 'Retry deployment (without cache)' : 'Deploy (without cache)' }}
|
||||
</button>
|
||||
@endif
|
||||
@if ($application->container_present !== false)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()" role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Remove container
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Redeploy
|
||||
</button>
|
||||
@if ($application->destination->server->isSwarm())
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
<x-slot:main wire:click="deploy">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Update Service
|
||||
</x-slot:main>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Redeploy
|
||||
</button>
|
||||
@endcan
|
||||
<x-slot:main @click="document.getElementById('application-mobile-stop-trigger')?.click()">
|
||||
<x-reicon name="stop-circle" class="size-3.5" />
|
||||
Stop
|
||||
</x-slot:main>
|
||||
@endif
|
||||
@else
|
||||
<x-slot:main wire:click="deploy">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Redeploy
|
||||
</x-slot:main>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $application))
|
||||
wire:click="{{ str($application->status)->startsWith('running') ? 'force_deploy_without_cache' : 'deploy(true)' }}"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
{{ str($application->status)->startsWith('running') ? 'Redeploy (without cache)' : 'Deploy (without cache)' }}
|
||||
</button>
|
||||
@endif
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
@if ($application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Update Service
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Update Service
|
||||
</button>
|
||||
@endcan
|
||||
@else
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@endcan
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
@can('deploy', $application)
|
||||
@unless ($application->destination->server->isSwarm() && $application->build_pack === 'dockercompose')
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
@endunless
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-split-action>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
'ok' => 'DNS OK',
|
||||
'failed' => 'DNS mismatch',
|
||||
'skipped' => 'DNS skipped',
|
||||
'checking' => 'Checking DNS...',
|
||||
'pending' => 'DNS pending',
|
||||
default => 'DNS unknown',
|
||||
};
|
||||
@@ -36,9 +37,10 @@
|
||||
->keys()
|
||||
->first();
|
||||
$showDirection = ($showDirectionControl ?? true) && ! $isSuggested && $firstPairRowIndex === $index;
|
||||
$domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
|
||||
@endphp
|
||||
|
||||
<div wire:key="domain-row-{{ $index }}-{{ md5(($isSuggested ? 's:' : '') . $row['url'] . '|' . ($row['service'] ?? '')) }}"
|
||||
<div wire:key="domain-row-{{ md5(($isSuggested ? 's:' : '') . $row['url'] . '|' . ($row['service'] ?? '')) }}"
|
||||
class="env-table-item">
|
||||
<div @class([
|
||||
'data-table-row',
|
||||
@@ -72,6 +74,17 @@
|
||||
title="{{ $row['url'] }}">
|
||||
{{ $row['url'] }}
|
||||
</a>
|
||||
@if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0)
|
||||
<span class="table-badge shrink-0"
|
||||
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from Ports Exposes' }}">
|
||||
Internal port {{ $row['internal_port'] }}
|
||||
</span>
|
||||
@else
|
||||
<span class="table-badge table-badge-danger shrink-0"
|
||||
title="Set Ports Exposes or a per-domain internal port so the proxy can route this domain.">
|
||||
No internal port
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
@if ($isSuggested && ! empty($row['suggestion_label']))
|
||||
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
|
||||
@@ -184,7 +197,7 @@
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
</button>
|
||||
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?" buttonTitle="Remove"
|
||||
isErrorButton submitAction="removeDomain({{ $index }})" :actions="[
|
||||
isErrorButton submitAction="removeDomainByKey({{ $domainKey }})" :actions="[
|
||||
'This domain will be removed from the application.',
|
||||
'Redeploy or restart may be required for proxy changes.',
|
||||
]" :confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Remove domain">
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<div class="flex flex-col gap-3" x-data="{ editOpen: false }"
|
||||
@open-preview-domain-edit.window="editOpen = true"
|
||||
@close-preview-domain-edit.window="editOpen = false">
|
||||
@if (collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'))
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
@endif
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="min-w-0 flex-1 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ count($domainRows) }} domain{{ count($domainRows) === 1 ? '' : 's' }}
|
||||
</p>
|
||||
@can('update', $preview->application)
|
||||
@if (count($domainRows) > 0)
|
||||
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled" wire:target="checkAllDns,checkDomainDns">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Recheck DNS
|
||||
</x-forms.button>
|
||||
@endif
|
||||
<x-modal-input title="Add domain" :closeOutside="false" :wireIgnore="false"
|
||||
canGate="update" :canResource="$preview->application">
|
||||
<x-slot:content>
|
||||
<button type="button" class="button button-highlighted">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
Add
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
|
||||
@if ($isCompose && count($composeServices) > 0)
|
||||
<x-forms.listbox id="newDomainService" label="Service" required
|
||||
:options="collect($composeServices)->map(fn ($service) => ['value' => $service, 'label' => $service])->all()" />
|
||||
@endif
|
||||
<x-forms.domain-input id="newDomainParts" />
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 pt-2">
|
||||
<x-forms.button type="button" wire:click="generateDomain">Generate domain</x-forms.button>
|
||||
<x-forms.button type="submit" isHighlighted>Save</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
@if (count($domainRows) === 0)
|
||||
<div class="application-settings-section-body">
|
||||
<x-empty size="sm" title="No domains configured"
|
||||
description="Add a domain or generate one with the server wildcard domain." icon-name="globe" />
|
||||
</div>
|
||||
@else
|
||||
<div class="application-settings-section-body is-flush overflow-visible">
|
||||
<div class="data-table-header domains-table-grid-service">
|
||||
<span>Domain</span>
|
||||
<span>DNS check</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
@foreach ($domainRows as $index => $row)
|
||||
@php
|
||||
$dnsType = match ($row['dns_status']) {
|
||||
'ok' => 'success',
|
||||
'failed' => 'error',
|
||||
'skipped' => 'warning',
|
||||
default => 'neutral',
|
||||
};
|
||||
$dnsLabel = match ($row['dns_status']) {
|
||||
'ok' => 'DNS OK',
|
||||
'failed' => 'DNS mismatch',
|
||||
'skipped' => 'DNS skipped',
|
||||
default => 'DNS pending',
|
||||
};
|
||||
$domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
|
||||
@endphp
|
||||
<div wire:key="preview-domain-{{ md5(($row['service'] ?? '') . $row['url']) }}" class="env-table-item">
|
||||
<div class="data-table-row domains-table-grid-service">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<x-reicon name="globe" class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" />
|
||||
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
|
||||
class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
|
||||
title="{{ $row['url'] }}">{{ $row['url'] }}</a>
|
||||
@if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0)
|
||||
<span class="table-badge shrink-0"
|
||||
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from Ports Exposes' }}">
|
||||
Internal port {{ $row['internal_port'] }}
|
||||
</span>
|
||||
@else
|
||||
<span class="table-badge table-badge-danger shrink-0"
|
||||
title="Set Ports Exposes or a per-domain internal port so the proxy can route this domain.">
|
||||
No internal port
|
||||
</span>
|
||||
@endif
|
||||
@if (filled($row['service']))
|
||||
<span class="table-badge shrink-0">{{ $row['service'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center">
|
||||
<x-status-badge :status="$dnsLabel" :type="$dnsType" :title="$row['dns_message']" />
|
||||
</div>
|
||||
<div class="hidden"></div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@can('update', $preview->application)
|
||||
<button type="button" wire:click="checkDomainDns({{ $index }})"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns"
|
||||
class="icon-button shrink-0" title="Check DNS" aria-label="Check DNS">
|
||||
<x-reicon name="refresh" class="size-3.5" wire:loading.remove.delay
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
|
||||
<x-loading-on-button wire:loading.delay
|
||||
wire:target="checkDomainDns({{ $index }}),checkAllDns" />
|
||||
</button>
|
||||
<button type="button" wire:click="startEdit({{ $index }})"
|
||||
class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
</button>
|
||||
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"
|
||||
buttonTitle="Remove" isErrorButton
|
||||
submitAction="removeDomainByKey({{ $domainKey }})"
|
||||
:actions="[
|
||||
'This domain will be removed from the preview deployment.',
|
||||
'Redeploy the preview to apply proxy changes.',
|
||||
]"
|
||||
:confirmWithPassword="false" :confirmWithText="false"
|
||||
step2ButtonText="Remove domain">
|
||||
<x-slot:trigger>
|
||||
<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="Remove domain" aria-label="Remove domain">
|
||||
<x-reicon name="trash" class="size-3.5" />
|
||||
</button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<template x-teleport="body">
|
||||
<div x-show="editOpen" x-cloak class="fixed inset-0 z-99 overflow-y-auto">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="editOpen = false"></div>
|
||||
<div class="relative flex min-h-full items-center justify-center p-4">
|
||||
<div x-show="editOpen" x-trap.inert.noscroll="editOpen"
|
||||
class="application-settings-form application-settings-section relative w-full max-w-3xl">
|
||||
<header>
|
||||
<h3>Edit domain</h3>
|
||||
<button type="button" @click="editOpen = false" class="icon-button" aria-label="Close">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body">
|
||||
<form wire:submit="updateDomain" class="flex flex-col gap-4">
|
||||
<x-forms.domain-input id="editingDomainParts" />
|
||||
<div class="flex justify-end">
|
||||
<x-forms.button type="submit" isHighlighted>Save</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@if ($showPortWarningModal)
|
||||
<div x-data="{ modalOpen: true }"
|
||||
@keydown.escape.window="modalOpen = false; $wire.call('cancelUseUnknownPort')"
|
||||
class="relative z-40">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
||||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Use a different port?</h3>
|
||||
<button type="button"
|
||||
@click="modalOpen = false; $wire.call('cancelUseUnknownPort')"
|
||||
class="icon-button" aria-label="Close">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Unrecognized internal port" class="mb-4">
|
||||
Port <strong>{{ $unrecognizedPort }}</strong> is not listed in Ports Exposes
|
||||
and is not used by any application domain. The proxy will still route to it,
|
||||
but the container may not be listening there.
|
||||
</x-callout>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button type="button" canGate="update" :canResource="$preview->application"
|
||||
@click="modalOpen = false; $wire.call('cancelUseUnknownPort')">
|
||||
Cancel
|
||||
</x-forms.button>
|
||||
<x-forms.button type="button" wire:click="confirmUseUnknownPort" canGate="update"
|
||||
:canResource="$preview->application"
|
||||
@click="modalOpen = false" isError>
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,6 +0,0 @@
|
||||
<form wire:submit="save"
|
||||
class="grid gap-3 rounded-lg bg-neutral-50 p-3 ring-1 ring-neutral-200 md:grid-cols-[minmax(0,1fr)_auto] md:items-end dark:bg-white/[0.025] dark:ring-white/[0.07]">
|
||||
<x-forms.input helper="One domain per preview." label="{{ Str::headline($serviceName) }} domain"
|
||||
id="domain" wire:change="save" canGate="update" :canResource="$preview->application" />
|
||||
<x-forms.button wire:click="generate">Generate domain</x-forms.button>
|
||||
</form>
|
||||
@@ -103,6 +103,7 @@
|
||||
Preview #{{ data_get($preview, 'pull_request_id') }}
|
||||
</h4>
|
||||
<x-status-summary :status="data_get($preview, 'status')" title="Preview status" />
|
||||
<x-application.restart-limit-warning :application="$preview" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,49 +252,19 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-white/[0.07]">
|
||||
@if ($application->build_pack === 'dockercompose')
|
||||
@if (collect(json_decode($preview->docker_compose_domains))->count() === 0)
|
||||
<form wire:submit="save_preview('{{ $preview->id }}')"
|
||||
class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||
<x-forms.input label="Domain" helper="One domain per preview."
|
||||
id="previewFqdns.{{ $previewName }}" canGate="update"
|
||||
:canResource="$application"
|
||||
wire:change="save_preview('{{ $preview->id }}')" />
|
||||
@can('update', $application)
|
||||
<x-forms.button wire:click="generate_preview('{{ $preview->id }}')">
|
||||
Generate domain
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
</form>
|
||||
@else
|
||||
<div class="flex flex-col gap-3">
|
||||
@foreach (collect(json_decode($preview->docker_compose_domains)) as $serviceName => $service)
|
||||
<livewire:project.application.previews-compose
|
||||
wire:key="preview-{{ $preview->pull_request_id }}-{{ $serviceName }}"
|
||||
:service="$service" :serviceName="$serviceName" :preview="$preview" />
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<livewire:project.application.preview-domains
|
||||
wire:key="preview-domains-{{ $preview->id }}"
|
||||
:preview="$preview" />
|
||||
|
||||
@if ($application->build_pack === 'dockerimage')
|
||||
<form wire:submit="save_preview('{{ $preview->id }}')"
|
||||
class="grid gap-3 {{ $application->build_pack === 'dockerimage'
|
||||
? 'md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]'
|
||||
: 'md:grid-cols-[minmax(0,1fr)_auto]' }} md:items-end">
|
||||
<x-forms.input label="Domain" helper="One domain per preview."
|
||||
id="previewFqdns.{{ $previewName }}" canGate="update"
|
||||
:canResource="$application"
|
||||
wire:change="save_preview('{{ $preview->id }}')" />
|
||||
@if ($application->build_pack === 'dockerimage')
|
||||
<x-forms.input label="Docker tag" helper="Image tag used by this preview."
|
||||
id="previewDockerTags.{{ $previewName }}" canGate="update"
|
||||
class="application-settings-section-body is-flush mt-3 overflow-visible">
|
||||
<div class="data-table-header grid-cols-1"><span>Docker tag</span></div>
|
||||
<div class="p-3">
|
||||
<x-forms.input id="previewDockerTags.{{ $previewName }}" canGate="update"
|
||||
:canResource="$application"
|
||||
wire:change="save_preview('{{ $preview->id }}')" />
|
||||
@endif
|
||||
@can('update', $application)
|
||||
<x-forms.button wire:click="generate_preview('{{ $preview->id }}')">
|
||||
Generate domain
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@@ -305,15 +276,4 @@
|
||||
@endforelse
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
|
||||
confirmAction="confirmDomainUsage">
|
||||
The preview deployment domain is already used by another resource and may cause routing conflicts.
|
||||
<x-slot:consequences>
|
||||
<ul class="mt-2 ml-4 list-disc">
|
||||
<li>The preview deployment may not be accessible.</li>
|
||||
<li>SSL certificates may not work correctly.</li>
|
||||
<li>Requests may be routed unpredictably.</li>
|
||||
</ul>
|
||||
</x-slot:consequences>
|
||||
</x-domain-conflict-modal>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<div wire:poll.10000ms="refreshStatus">
|
||||
<x-status-summary :status="$application->status" />
|
||||
<x-application.restart-limit-warning :application="$application" />
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
@endif
|
||||
@elseif ($currentRoute === 'project.database.environment-variables')
|
||||
<livewire:project.shared.environment-variable.all :resource="$database" />
|
||||
<livewire:project.shared.secret-manager-links :resource="$database" />
|
||||
@elseif ($currentRoute === 'project.database.servers')
|
||||
<livewire:project.shared.destination :resource="$database" />
|
||||
@elseif ($currentRoute === 'project.database.persistent-storage')
|
||||
|
||||
@@ -63,71 +63,36 @@
|
||||
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
{{ $database->name }}
|
||||
</h1>
|
||||
<x-status-summary :status="$database->status" title="Database status" />
|
||||
<div class="relative flex w-full min-w-0 items-center gap-2">
|
||||
<x-status-summary :status="$database->status" title="Database status" />
|
||||
</div>
|
||||
<div class="flex w-full flex-wrap gap-1">
|
||||
<x-application.restart-limit-warning :application="$database" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full xl:hidden">
|
||||
@if ($database->destination->server->isFunctional())
|
||||
@can('manage', $database)
|
||||
<div id="database-mobile-actions" class="relative mb-3"
|
||||
x-data="{ open: false }" @click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<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">
|
||||
Actions
|
||||
</span>
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.left
|
||||
class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="menu">
|
||||
<x-split-action id="database-mobile-actions" class="mb-3 flex w-full">
|
||||
@if (! $databaseStatus->startsWith('exited'))
|
||||
@can('manage', $database)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('database-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('database-stop-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="stop" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
<x-slot:main @click="document.getElementById('database-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5" />
|
||||
Restart
|
||||
</x-slot:main>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('database-stop-trigger')?.click()" role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
@can('manage', $database)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; $wire.dispatch('startEvent')" role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Start
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Start
|
||||
</button>
|
||||
@endcan
|
||||
<x-slot:main @click="$wire.dispatch('startEvent')">
|
||||
<x-reicon name="play-circle" class="size-3.5" />
|
||||
Start
|
||||
</x-slot:main>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-split-action>
|
||||
@endcan
|
||||
@endif
|
||||
|
||||
@@ -140,25 +105,24 @@
|
||||
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
|
||||
@if ($database->destination->server->isFunctional())
|
||||
@can('manage', $database)
|
||||
<div id="database-desktop-actions" class="flex items-center gap-0.5">
|
||||
@if (! $databaseStatus->startsWith('exited'))
|
||||
<button type="button" class="button button-highlighted"
|
||||
@disabled(!auth()->user()->can('manage', $database))
|
||||
@click="document.getElementById('database-restart-trigger')?.click()">
|
||||
Restart
|
||||
</button>
|
||||
<button type="button" class="button"
|
||||
@disabled(!auth()->user()->can('manage', $database))
|
||||
@click="document.getElementById('database-stop-trigger')?.click()">
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<x-forms.button class="button-highlighted" canGate="manage" :canResource="$database"
|
||||
@click="$wire.dispatch('startEvent')">
|
||||
Start
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<x-split-action id="database-desktop-actions">
|
||||
@if (! $databaseStatus->startsWith('exited'))
|
||||
<x-slot:main @click="document.getElementById('database-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5" />
|
||||
Restart
|
||||
</x-slot:main>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('database-stop-trigger')?.click()" role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<x-slot:main @click="$wire.dispatch('startEvent')">
|
||||
<x-reicon name="play-circle" class="size-3.5" />
|
||||
Start
|
||||
</x-slot:main>
|
||||
@endif
|
||||
</x-split-action>
|
||||
@endcan
|
||||
@else
|
||||
<x-status-badge status="Server unavailable" type="error" />
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
<div class="space-y-4">
|
||||
@if ($resourceDbType === 'standalone-postgresql')
|
||||
@if ($dumpAll)
|
||||
<x-callout type="warning" title="Full restore overwrites administrator passwords">
|
||||
The backup replaces PostgreSQL administrator role passwords, including the destination administrator password.
|
||||
<span class="mt-1 block">If the administrator password changes, update it in Coolify's database configuration after the restore.</span>
|
||||
</x-callout>
|
||||
<x-forms.textarea rows="6" readonly label="Import command"
|
||||
wire:model="restoreCommandText" canGate="update"
|
||||
:canResource="$this->resource" />
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
<div class="divide-y divide-neutral-200 dark:divide-border-subtle">
|
||||
@forelse($initScripts ?? [] as $script)
|
||||
<livewire:project.database.init-script :database="$database" :script="$script"
|
||||
:wire:key="$script['index']" />
|
||||
:wire:key="'init-script-'.md5($script['filename'])" />
|
||||
@empty
|
||||
<x-empty title="No initialization scripts"
|
||||
description="Add a SQL file to run during the first PostgreSQL initialization." />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<div wire:poll.10000ms="refreshStatus">
|
||||
<x-status-summary :status="$database->status" title="Database status" />
|
||||
<x-application.restart-limit-warning :application="$database" />
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<div class="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<img x-cloak x-show="preview" :src="preview" alt="Project icon preview" class="h-full w-full object-cover">
|
||||
@if ($project->icon_path)
|
||||
<img x-show="!preview" src="{{ route('project.icon', ['project_uuid' => $project->uuid, 'v' => $project->updated_at->timestamp]) }}"
|
||||
<img x-show="!preview" src="{{ project_icon_url($project) }}"
|
||||
alt="{{ $project->name }} icon" class="h-full w-full object-cover">
|
||||
@else
|
||||
<x-reicon x-show="!preview" name="projects" class="size-6" />
|
||||
|
||||
@@ -287,15 +287,8 @@
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-white/[0.08] dark:bg-white/[0.04]">
|
||||
<template x-if="service.has_logo">
|
||||
<img class="h-full w-full object-contain p-2" :src="service.logo"
|
||||
onerror="this.onerror=null; this.src=this.getAttribute('data-fallback');"
|
||||
:data-fallback="service.logo_github_url" />
|
||||
</template>
|
||||
<template x-if="!service.has_logo">
|
||||
<x-reicon name="layers"
|
||||
class="size-6 text-neutral-400 dark:text-fg-faint" />
|
||||
</template>
|
||||
<img class="h-full w-full object-contain p-2" :src="service.logo"
|
||||
x-on:error="if (!$el.dataset.cdnTried) { $el.dataset.cdnTried = 'true'; $el.src = service.logo_cdn_url; } else if (!$el.dataset.defaultTried) { $el.dataset.defaultTried = 'true'; $el.src = service.logo_default_url; }" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-[13px] font-semibold text-black dark:text-fg"
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
x-text="item.typeLabel"></div>
|
||||
|
||||
<div>
|
||||
<x-status-badge dynamic>
|
||||
<x-status-badge dynamic x-bind:title="statusTitle(item)">
|
||||
<span class="size-1.5 shrink-0 rounded-full"
|
||||
x-bind:class="statusDotClass(item)"></span>
|
||||
<span class="truncate" x-text="statusLabel(item)"></span>
|
||||
@@ -299,7 +299,7 @@
|
||||
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint"
|
||||
x-text="item.typeLabel"></p>
|
||||
</div>
|
||||
<x-status-badge dynamic>
|
||||
<x-status-badge dynamic x-bind:title="statusTitle(item)">
|
||||
<span class="size-1.5 shrink-0 rounded-full"
|
||||
x-bind:class="statusDotClass(item)"></span>
|
||||
<span class="truncate" x-text="statusLabel(item)"></span>
|
||||
@@ -314,8 +314,6 @@
|
||||
class="relative z-10 max-w-full self-start truncate text-[11px] text-neutral-500 hover:underline dark:text-fg-dim"
|
||||
:title="displayDomain(item.fqdn)" x-text="displayDomain(item.fqdn)"></a>
|
||||
</template>
|
||||
<span x-show="!item.fqdn"
|
||||
class="text-[11px] text-neutral-400 dark:text-fg-faint">-</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -518,13 +516,32 @@
|
||||
localStorage.setItem('environment-resource-view', mode);
|
||||
},
|
||||
statusState(item) {
|
||||
if (item.restartLimitReached) {
|
||||
return 'restart-limit';
|
||||
}
|
||||
|
||||
return String(item.status || 'unknown').split(':')[0].toLowerCase();
|
||||
},
|
||||
statusLabel(item) {
|
||||
if (item.restartLimitReached) {
|
||||
return 'Restart limit reached';
|
||||
}
|
||||
|
||||
const state = this.statusState(item);
|
||||
return state.charAt(0).toUpperCase() + state.slice(1);
|
||||
},
|
||||
statusTitle(item) {
|
||||
if (item.restartLimitReached) {
|
||||
return `${item.restartCount}/${item.maxRestartCount} restarts. Container preserved.`;
|
||||
}
|
||||
|
||||
return this.statusLabel(item);
|
||||
},
|
||||
statusTone(item) {
|
||||
if (item.restartLimitReached) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
const state = this.statusState(item);
|
||||
if (state === 'running') {
|
||||
return 'success';
|
||||
@@ -539,6 +556,10 @@
|
||||
return 'neutral';
|
||||
},
|
||||
statusDotClass(item) {
|
||||
if (item.restartLimitReached) {
|
||||
return 'bg-warning';
|
||||
}
|
||||
|
||||
return {
|
||||
success: 'bg-emerald-500',
|
||||
warning: 'bg-warning',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<div>
|
||||
@if ($selectedExecution)
|
||||
<x-modal-input title="Backup execution" wireOpen="executionModalOpen" :wireIgnore="false" isLarge>
|
||||
<x-slot:content><span></span></x-slot:content>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Target</p><p class="mt-1 text-sm font-medium">{{ $selectedExecution['target'] }}</p></div>
|
||||
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Status</p><p class="mt-1 text-sm font-medium">{{ str($selectedExecution['status'])->headline() }}</p></div>
|
||||
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Started</p><p class="mt-1 text-sm font-medium">{{ $selectedExecution['started_at']->diffForHumans() }}</p></div>
|
||||
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Size</p><p class="mt-1 text-sm font-medium">{{ $selectedExecution['size'] ? formatBytes($selectedExecution['size']) : '-' }}</p></div>
|
||||
</div>
|
||||
@if ($selectedExecution['filename'])
|
||||
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Backup path</p><code class="mt-1 block overflow-x-auto rounded-md bg-neutral-100 p-3 text-xs dark:bg-black/20">{{ $selectedExecution['filename'] }}</code></div>
|
||||
@endif
|
||||
@if ($selectedExecution['message'])
|
||||
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Output</p><pre class="mt-1 max-h-80 overflow-auto rounded-md bg-neutral-100 p-3 font-mono text-xs whitespace-pre-wrap dark:bg-black/20">{{ $selectedExecution['message'] }}</pre></div>
|
||||
@endif
|
||||
</div>
|
||||
</x-modal-input>
|
||||
@endif
|
||||
|
||||
<x-application.settings-section title="Executions"
|
||||
helper="Review backup runs across every database and storage target in this service." flush>
|
||||
@if ($executions->isEmpty())
|
||||
<x-empty size="sm" title="No backup executions"
|
||||
description="Execution history appears here after a backup schedule runs." icon-name="browser-terminal" />
|
||||
@else
|
||||
<div class="data-table w-full overflow-x-auto">
|
||||
<div class="data-table-header grid min-w-[820px] grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_110px_90px_48px]">
|
||||
<span>Target</span><span>Type</span><span>Schedule</span><span>Status</span><span>Started</span><span>Size</span><span class="text-right">Actions</span>
|
||||
</div>
|
||||
@foreach ($executions as $execution)
|
||||
@php
|
||||
$statusType = match ($execution['status']) {
|
||||
'success' => 'success',
|
||||
'failed' => 'error',
|
||||
'running' => 'warning',
|
||||
default => 'neutral',
|
||||
};
|
||||
@endphp
|
||||
<div wire:key="service-backup-execution-{{ $execution['id'] }}"
|
||||
wire:click="openExecution('{{ $execution['uuid'] }}')"
|
||||
wire:keydown.enter="openExecution('{{ $execution['uuid'] }}')" role="button" tabindex="0"
|
||||
class="data-table-row grid min-w-[820px] cursor-pointer grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_110px_90px_48px] text-left text-[13px] text-neutral-700 dark:text-fg-dim">
|
||||
<span class="truncate font-medium text-neutral-950 dark:text-fg" title="{{ $execution['target'] }}">{{ $execution['target'] }}</span>
|
||||
<span>{{ $execution['type'] }}</span><span>{{ $execution['schedule'] }}</span>
|
||||
<span><x-status-badge :status="str($execution['status'])->headline()" :type="$statusType" /></span>
|
||||
<span>{{ $execution['started_at']->diffForHumans() }}</span>
|
||||
<span>{{ $execution['size'] ? formatBytes($execution['size']) : '-' }}</span>
|
||||
<span class="flex justify-end">
|
||||
@if ($execution['download_url'])
|
||||
<a href="{{ $execution['download_url'] }}" target="_blank" rel="noopener"
|
||||
@click.stop class="icon-button shrink-0" title="Download backup"
|
||||
aria-label="Download backup">
|
||||
<x-reicon name="upload" class="size-3.5 rotate-180" />
|
||||
</a>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@@ -18,6 +18,7 @@
|
||||
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
|
||||
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
|
||||
['label' => 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false],
|
||||
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
|
||||
@@ -35,7 +36,7 @@
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
|
||||
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'],
|
||||
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
|
||||
@@ -143,12 +144,12 @@
|
||||
|
||||
<div :class="viewMode === 'grid'
|
||||
? 'grid grid-cols-1 gap-3 sm:grid-cols-2'
|
||||
: 'overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]'">
|
||||
: 'overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]'">
|
||||
@if ($applications->isNotEmpty() || $databases->isNotEmpty())
|
||||
<div x-cloak x-show="viewMode === 'table'"
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8rem_5rem] dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||||
class="grid min-w-[48rem] grid-cols-[minmax(14rem,1fr)_minmax(12rem,1fr)_12rem_5rem] gap-3 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>Resource</div>
|
||||
<div class="hidden sm:block">Image</div>
|
||||
<div>Image</div>
|
||||
<div class="justify-self-start">Status</div>
|
||||
<div></div>
|
||||
</div>
|
||||
@@ -179,6 +180,7 @@
|
||||
<livewire:project.service.domains :service="$service" />
|
||||
@elseif ($currentRoute === 'project.service.environment-variables')
|
||||
<livewire:project.shared.environment-variable.all :resource="$service" />
|
||||
<livewire:project.shared.secret-manager-links :resource="$service" />
|
||||
@elseif ($currentRoute === 'project.service.storages')
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
|
||||
@@ -8,24 +8,41 @@
|
||||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
@if ($backup)
|
||||
<x-backup-sidebar context="service" :parameters="$backupParameters" :section="$section" />
|
||||
@else
|
||||
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase"
|
||||
:isImportSupported="$isImportSupported" />
|
||||
@endif
|
||||
<x-service.configuration-sidebar :service="$service"
|
||||
current-route="project.service.volume-backups.index" />
|
||||
|
||||
<div class="min-w-0">
|
||||
@if ($backup)
|
||||
@if ($section === 'executions')
|
||||
<livewire:project.database.backup-executions :backup="$backup"
|
||||
:database="$serviceDatabase" />
|
||||
@else
|
||||
<livewire:project.database.backup-edit :backup="$backup"
|
||||
:available-s3-storages="$s3s" :status="data_get($serviceDatabase, 'status')"
|
||||
:section="$section"
|
||||
wire:key="service-database-backup-{{ $backup->uuid }}-{{ $section }}" />
|
||||
@endif
|
||||
<div class="flex min-w-0 flex-col gap-6">
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<div>
|
||||
<a class="inline-flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-900 dark:text-fg-dim dark:hover:text-fg"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('project.service.volume-backups.index', collect($parameters)->except(['stack_service_uuid', 'backup_uuid'])->all()) }}">
|
||||
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
|
||||
Back to backups
|
||||
</a>
|
||||
<h1 class="mt-2 text-xl font-semibold text-neutral-950 dark:text-fg">
|
||||
{{ $serviceDatabase->human_name ?: $serviceDatabase->name }} backup
|
||||
</h1>
|
||||
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $backup->frequency }} schedule
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<x-backup-tabs context="service" :parameters="$backupParameters" :section="$section" />
|
||||
</div>
|
||||
|
||||
@if ($section === 'executions')
|
||||
<livewire:project.database.backup-executions :backup="$backup"
|
||||
:database="$serviceDatabase" />
|
||||
@else
|
||||
<livewire:project.database.backup-edit :backup="$backup"
|
||||
:available-s3-storages="$s3s" :status="data_get($serviceDatabase, 'status')"
|
||||
:section="$section"
|
||||
wire:key="service-database-backup-{{ $backup->uuid }}-{{ $section }}" />
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<section class="application-settings-section">
|
||||
<div class="application-settings-section-header">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
$configuredCount = collect($domainRows)->where('is_suggested', false)->count();
|
||||
$suggestedCount = collect($domainRows)->where('is_suggested', true)->count();
|
||||
$hasRows = count($domainRows) > 0;
|
||||
$hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking');
|
||||
$serviceAppCount = count($serviceApps);
|
||||
$domainGroups = collect($domainRows)
|
||||
->groupBy('service_application_id')
|
||||
@@ -37,6 +38,9 @@
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain()"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
@if ($hasDnsChecksInProgress)
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
@endif
|
||||
<x-application.settings-section id="service-domains-section" title="Domains">
|
||||
@can('update', $service)
|
||||
<x-slot:actions>
|
||||
@@ -279,4 +283,46 @@
|
||||
|
||||
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
|
||||
confirmAction="confirmDomainUsage" />
|
||||
|
||||
@if ($showPortWarningModal)
|
||||
<div x-data="{ modalOpen: true }"
|
||||
@keydown.escape.window="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
:class="{ 'z-40': modalOpen }" class="relative">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
||||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Use a different port?</h3>
|
||||
<button type="button"
|
||||
@click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="icon-button" aria-label="Close">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Port requirement" class="mb-4">
|
||||
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
|
||||
One or more of your domains use a different port, or none.
|
||||
</x-callout>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button type="button"
|
||||
@click="modalOpen = false; $wire.call('cancelRemovePort')">
|
||||
Keep required port
|
||||
</x-forms.button>
|
||||
<x-forms.button type="button" wire:click="confirmRemovePort"
|
||||
@click="modalOpen = false" isError>
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</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]">
|
||||
class="flex flex-col items-stretch gap-2 rounded-lg border border-neutral-200 bg-neutral-50 p-1 sm:flex-row sm:flex-wrap sm:items-center 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 />
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Remove required port?</h3>
|
||||
<h3>Use a different port?</h3>
|
||||
<button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="flex size-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
@@ -53,7 +53,7 @@
|
||||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Port requirement" class="mb-4">
|
||||
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
|
||||
One or more of your domains are missing a port number.
|
||||
One or more of your domains use a different port, or none.
|
||||
</x-callout>
|
||||
|
||||
<x-callout type="danger" title="What will happen if you continue?" class="mb-4">
|
||||
@@ -68,11 +68,11 @@
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="w-auto">
|
||||
Keep port
|
||||
Keep required port
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="confirmRemovePort" @click="modalOpen = false" class="w-auto"
|
||||
isError>
|
||||
Remove port anyway
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,85 +74,45 @@
|
||||
<div class="w-full xl:hidden">
|
||||
@if ($service->isDeployable)
|
||||
@can('deploy', $service)
|
||||
<div id="service-mobile-actions" class="relative mb-3"
|
||||
x-data="{ open: false }" @click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<button type="button" class="button w-full justify-between" x-bind:disabled="deploying"
|
||||
@click="open = !open"
|
||||
:aria-expanded="open" aria-haspopup="menu">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<x-loading-on-button x-show="deploying" x-cloak />
|
||||
<span x-text="deploying ? 'Deploying…' : 'Actions'">Actions</span>
|
||||
</span>
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.left
|
||||
class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="menu">
|
||||
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
|
||||
@can('deploy', $service)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('service-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart current version
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart current version
|
||||
</button>
|
||||
@endcan
|
||||
<x-split-action id="service-mobile-actions" class="mb-3 flex w-full">
|
||||
@if ($selectedResource && $selectedResource->container_present !== false && $selectedResourceStatus->startsWith('exited'))
|
||||
<x-slot:main @click="document.getElementById('selected-resource-remove-trigger')?.click()">
|
||||
<x-reicon name="trash" class="size-3.5" />
|
||||
Remove container
|
||||
</x-slot:main>
|
||||
@elseif ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
|
||||
<x-slot:main x-bind:disabled="deploying"
|
||||
@click="document.getElementById('service-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5" />
|
||||
Restart
|
||||
</x-slot:main>
|
||||
@if ($serviceStatus->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false"
|
||||
role="menuitem">
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Pull latest and restart
|
||||
Restart (pull latest)
|
||||
</button>
|
||||
@else
|
||||
@endif
|
||||
@if ($serviceStatus->contains('degraded'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false"
|
||||
role="menuitem">
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Restart
|
||||
</button>
|
||||
@endif
|
||||
@can('stop', $service)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
@else
|
||||
@can('deploy', $service)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; deploying = true; $wire.dispatch('startEvent')" role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@endcan
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()" role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<x-slot:main x-bind:disabled="deploying" @click="deploying = true; $wire.dispatch('startEvent')">
|
||||
<x-loading-on-button x-show="deploying" x-cloak />
|
||||
<x-reicon name="play-circle" class="size-3.5" x-show="!deploying" />
|
||||
<span x-text="deploying ? 'Deploying…' : 'Deploy'">Deploy</span>
|
||||
</x-slot:main>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Deploy
|
||||
@@ -164,8 +124,7 @@
|
||||
Force Cleanup Containers
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-split-action>
|
||||
@endcan
|
||||
@else
|
||||
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
|
||||
@@ -186,70 +145,57 @@
|
||||
<x-services.links :service="$service" />
|
||||
</div>
|
||||
@can('deploy', $service)
|
||||
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button button-highlighted" @click="open = !open" :aria-expanded="open">
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-64! min-w-0!" role="menu">
|
||||
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
|
||||
<x-split-action id="service-desktop-actions">
|
||||
@if ($selectedResource && $selectedResource->container_present !== false && $selectedResourceStatus->startsWith('exited'))
|
||||
<x-slot:main @click="document.getElementById('selected-resource-remove-trigger')?.click()">
|
||||
<x-reicon name="trash" class="size-3.5" />
|
||||
Remove container
|
||||
</x-slot:main>
|
||||
@elseif ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
|
||||
<x-slot:main x-bind:disabled="deploying"
|
||||
@click="document.getElementById('service-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5" />
|
||||
Restart
|
||||
</x-slot:main>
|
||||
@if ($serviceStatus->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="open = false; document.getElementById('service-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Restart (pull latest)
|
||||
</button>
|
||||
@if ($serviceStatus->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Restart (pull latest)
|
||||
</button>
|
||||
@endif
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@elseif (! $serviceStatus->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="deploying = true; $wire.dispatch('startEvent'); open = false">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@endif
|
||||
@if (! $serviceStatus->contains('running'))
|
||||
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300" role="separator"></div>
|
||||
@endif
|
||||
@if ($serviceStatus->contains('degraded'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false">
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Restart
|
||||
</button>
|
||||
@elseif (! $serviceStatus->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Deploy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="$wire.dispatch('cleanupEvent'); open = false">
|
||||
<x-reicon name="trash" class="size-3.5 opacity-70" />
|
||||
Force Cleanup Containers
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()" role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<x-slot:main x-bind:disabled="deploying" @click="deploying = true; $wire.dispatch('startEvent')">
|
||||
<x-loading-on-button x-show="deploying" x-cloak />
|
||||
<x-reicon name="play-circle" class="size-3.5" x-show="!deploying" />
|
||||
<span x-text="deploying ? 'Deploying…' : 'Deploy'">Deploy</span>
|
||||
</x-slot:main>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Deploy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="$wire.dispatch('cleanupEvent'); open = false" role="menuitem">
|
||||
<x-reicon name="trash" class="size-3.5 opacity-70" />
|
||||
Force Cleanup Containers
|
||||
</button>
|
||||
@endif
|
||||
</x-split-action>
|
||||
@endcan
|
||||
@else
|
||||
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
|
||||
@@ -281,6 +227,17 @@
|
||||
<button id="service-stop-trigger" type="button">Stop</button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
@if ($selectedResource)
|
||||
<x-modal-confirmation title="Confirm Container Removal?" buttonTitle="Remove container"
|
||||
canGate="deploy" :canResource="$service" submitAction="removeSelectedResourceContainer"
|
||||
:actions="['The exited service resource container will be removed.', __('resource.non_persistent')]"
|
||||
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue"
|
||||
step2ButtonText="Confirm">
|
||||
<x-slot:trigger>
|
||||
<button id="selected-resource-remove-trigger" type="button">Remove container</button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($service, 'name')->limit(10) }} > Import Backup | Coolify
|
||||
</x-slot>
|
||||
|
||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
|
||||
wire:key="service-heading-import-backup" />
|
||||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-service.configuration-sidebar :service="$service" current-route="project.service.import-backup" />
|
||||
|
||||
<div class="application-settings-form min-w-0 flex flex-col gap-6">
|
||||
@if ($databases->isEmpty())
|
||||
<x-application.settings-section title="Import Backup"
|
||||
helper="Restore a backup into a database in this service.">
|
||||
<x-empty title="No compatible databases"
|
||||
description="This service does not contain a database that supports backup imports."
|
||||
icon-name="database" size="sm" />
|
||||
</x-application.settings-section>
|
||||
@else
|
||||
<x-application.settings-section title="Import Backup"
|
||||
helper="Choose the database that should receive the backup.">
|
||||
<x-forms.listbox id="selectedDatabaseUuid" label="Database" live required canGate="update"
|
||||
:canResource="$service"
|
||||
:options="$databases->map(fn ($database) => [
|
||||
'value' => $database->uuid,
|
||||
'label' => $database->human_name ?: $database->name,
|
||||
])->all()" />
|
||||
</x-application.settings-section>
|
||||
|
||||
@if ($selectedDatabase)
|
||||
<livewire:project.database.import :key="'service-import-' . $selectedDatabase->uuid" />
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
@if ($resourceType === 'database')
|
||||
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
|
||||
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" />
|
||||
@else
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Compose resource settings"
|
||||
@@ -175,7 +175,7 @@
|
||||
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header>
|
||||
<h3>Remove required port?</h3>
|
||||
<h3>Use a different port?</h3>
|
||||
<button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="flex size-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
@@ -184,7 +184,7 @@
|
||||
<div class="application-settings-section-body">
|
||||
<x-callout type="warning" title="Port requirement" class="mb-4">
|
||||
This service requires port <strong>{{ $requiredPort }}</strong> to function correctly.
|
||||
One or more of your domains are missing a port number.
|
||||
One or more of your domains use a different port, or none.
|
||||
</x-callout>
|
||||
|
||||
<x-callout type="danger" title="What will happen if you continue?" class="mb-4">
|
||||
@@ -199,11 +199,11 @@
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button @click="modalOpen = false; $wire.call('cancelRemovePort')"
|
||||
class="w-auto">
|
||||
Keep port
|
||||
Keep required port
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="confirmRemovePort" @click="modalOpen = false" class="w-auto"
|
||||
isError>
|
||||
Remove port anyway
|
||||
Use this port anyway
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
'ok' => 'DNS OK',
|
||||
'failed' => 'DNS mismatch',
|
||||
'skipped' => 'DNS skipped',
|
||||
'checking' => 'Checking DNS...',
|
||||
'pending' => 'DNS pending',
|
||||
default => 'DNS unknown',
|
||||
};
|
||||
@@ -60,9 +61,10 @@
|
||||
->reject(fn ($item) => (bool) ($item['is_suggested'] ?? false))
|
||||
->first(fn ($item) => $redirectPairKey($item['url']) === $pairKey)['url'] ?? null;
|
||||
$showDirection = ! $isSuggested && $firstPairRowUrl === $row['url'];
|
||||
$domainKey = hash('sha256', $row['url'].'|'.($row['service_application_id'] ?? ''));
|
||||
@endphp
|
||||
|
||||
<div wire:key="svc-domain-{{ $row['service_application_id'] ?? 'x' }}-{{ $index }}-{{ md5(($isSuggested ? 's:' : '') . $row['url']) }}"
|
||||
<div wire:key="svc-domain-{{ $row['service_application_id'] ?? 'x' }}-{{ md5(($isSuggested ? 's:' : '') . $row['url']) }}"
|
||||
class="env-table-item">
|
||||
<div @class([
|
||||
'data-table-row',
|
||||
@@ -96,6 +98,12 @@
|
||||
title="{{ $row['url'] }}">
|
||||
{{ $row['url'] }}
|
||||
</a>
|
||||
@if (filled($row['internal_port'] ?? null) && (int) $row['internal_port'] > 0)
|
||||
<span class="table-badge shrink-0"
|
||||
title="{{ ($row['has_port_override'] ?? false) ? 'Custom internal port for this domain' : 'Inherited from the Coolify service port' }}">
|
||||
Internal port {{ $row['internal_port'] }}
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
@if ($isSuggested && ! empty($row['suggestion_label']))
|
||||
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
|
||||
@@ -207,7 +215,7 @@
|
||||
</button>
|
||||
<x-modal-confirmation class="!w-auto shrink-0" title="Remove domain?"
|
||||
buttonTitle="Remove" isErrorButton
|
||||
submitAction="removeDomain({{ $index }})" :actions="[
|
||||
submitAction="removeDomainByKey({{ $domainKey }})" :actions="[
|
||||
'This domain will be removed from the service application.',
|
||||
'Redeploy or restart may be required for proxy changes.',
|
||||
]" :confirmWithPassword="false" :confirmWithText="false"
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
<div>
|
||||
<div x-data="{
|
||||
settingsUrl: @js(route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid])),
|
||||
openSettings(event) {
|
||||
if (event.target.closest('a, button')) {
|
||||
return;
|
||||
}
|
||||
Livewire.navigate(this.settingsUrl);
|
||||
}
|
||||
}">
|
||||
@php
|
||||
[$statusType, $statusLabel] = match (true) {
|
||||
str($resource->status)->contains('running') => ['success', formatContainerStatus($resource->status)],
|
||||
@@ -26,6 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<x-status-badge :status="$statusLabel" :type="$statusType" />
|
||||
<x-application.restart-limit-warning :application="$resource" />
|
||||
</div>
|
||||
|
||||
@if ($resource->configuration_required)
|
||||
@@ -76,7 +85,9 @@
|
||||
</div>
|
||||
|
||||
<div x-cloak x-show="viewMode === 'table'"
|
||||
class="grid min-h-14 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8rem_5rem] dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
x-on:click="openSettings($event)" x-on:keydown.enter="openSettings($event)"
|
||||
role="link" tabindex="0" aria-label="Open {{ $resourceName }} settings"
|
||||
class="grid min-h-14 min-w-[48rem] cursor-pointer grid-cols-[minmax(14rem,1fr)_minmax(12rem,1fr)_12rem_5rem] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary 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 bg-neutral-100 text-neutral-500 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
@@ -86,14 +97,14 @@
|
||||
<div class="truncate text-[13px] font-semibold text-black dark:text-fg">{{ $resourceName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden truncate font-mono text-xs text-neutral-500 sm:block dark:text-fg-faint">
|
||||
<div class="truncate font-mono text-xs text-neutral-500 dark:text-fg-faint">
|
||||
{{ $resource->image }}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-1 sm:contents">
|
||||
<div class="justify-self-start">
|
||||
<x-status-badge :status="$statusLabel" :type="$statusType" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<x-status-badge :status="$statusLabel" :type="$statusType" />
|
||||
<x-application.restart-limit-warning :application="$resource" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
|
||||
<a class="icon-button" title="Service backups" aria-label="Service backups" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.volume-backups.index', $parameters) }}">
|
||||
@@ -109,10 +120,9 @@
|
||||
@endcan
|
||||
@endif
|
||||
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
<x-reicon name="settings" class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<x-slot:headerActions>
|
||||
<div x-data="{ preview: false, validating: false, saving: false }"
|
||||
@compose-validate-finished.window="validating = false"
|
||||
@compose-save-finished.window="saving = false" class="flex items-center gap-2">
|
||||
@compose-save-finished.window="saving = false" class="flex w-full items-center gap-2 overflow-x-auto sm:w-auto">
|
||||
<x-forms.button
|
||||
@click="preview = !preview; $dispatch('compose-preview-toggle')">
|
||||
<x-reicon name="eye" class="size-3.5" />
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
<div wire:poll.10000ms="refreshStatus">
|
||||
<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />
|
||||
@php($displayStatus = $selectedResource?->status ?? $service->status)
|
||||
<x-status-summary :status="$displayStatus" :title="$selectedResource ? 'Resource status' : 'Service status'"
|
||||
:container-name="$selectedResource ? 'Container' : 'Containers'" />
|
||||
@if ($selectedResource)
|
||||
<x-application.restart-limit-warning :application="$selectedResource" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
icon-name="storages" />
|
||||
@elseif ($activeTab === 'volumes')
|
||||
@if ($hasVolumes)
|
||||
<livewire:project.shared.storages.all wire:key="volumes-{{ $resource->id }}-{{ $this->volumeCount }}"
|
||||
<livewire:project.shared.storages.all wire:key="volumes-{{ $resource->id }}"
|
||||
:resource="$resource" />
|
||||
@else
|
||||
<x-empty size="sm" title="No volumes configured"
|
||||
@@ -433,7 +433,7 @@
|
||||
@elseif ($activeTab === 'volumes')
|
||||
@if ($hasVolumes)
|
||||
<livewire:project.shared.storages.all
|
||||
wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"
|
||||
wire:key="svc-volumes-{{ $resource->id }}"
|
||||
:resource="$resource" />
|
||||
@else
|
||||
<x-empty size="sm" title="No volumes configured"
|
||||
|
||||
@@ -56,6 +56,44 @@
|
||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
|
||||
wire:key="service-heading-volume-backup-index" />
|
||||
|
||||
@if ($selectedDatabaseBackup || $selectedVolumeBackup)
|
||||
@php
|
||||
$selectedSchedule = $selectedDatabaseBackup ?: $selectedVolumeBackup;
|
||||
@endphp
|
||||
<x-modal-input :title="'Edit backup schedule'" wireOpen="scheduleModalOpen" :wireIgnore="false" isLarge
|
||||
canGate="update" :canResource="$service">
|
||||
<x-slot:content><span></span></x-slot:content>
|
||||
|
||||
<div x-data="{ activeSection: 'general' }" class="flex min-w-0 flex-col gap-6">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-neutral-950 dark:text-fg">
|
||||
{{ $selectedDatabaseBackup
|
||||
? ($selectedDatabaseBackup->database->human_name ?: $selectedDatabaseBackup->database->name)
|
||||
: $selectedVolumeBackup->targetName() }}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">{{ $selectedSchedule->frequency }} schedule</p>
|
||||
</div>
|
||||
|
||||
<x-backup-tabs context="service-schedule" :parameters="$parameters" section="general" />
|
||||
|
||||
@foreach (['general', 's3', 'retention', 'danger'] as $modalSection)
|
||||
<div x-show="activeSection === '{{ $modalSection }}'" x-cloak>
|
||||
@if ($selectedDatabaseBackup)
|
||||
<livewire:project.database.backup-edit :backup="$selectedDatabaseBackup"
|
||||
:available-s3-storages="$s3s" :status="data_get($selectedDatabaseBackup->database, 'status')"
|
||||
:section="$modalSection"
|
||||
wire:key="service-database-backup-modal-{{ $selectedDatabaseBackup->uuid }}-{{ $modalSection }}" />
|
||||
@else
|
||||
<livewire:project.shared.storages.volume-backups :storage="$selectedVolumeBackup->backupable"
|
||||
:resource="$service" :section="$modalSection"
|
||||
wire:key="service-volume-backup-modal-{{ $selectedVolumeBackup->uuid }}-{{ $modalSection }}" />
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-modal-input>
|
||||
@endif
|
||||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-service.configuration-sidebar :service="$service"
|
||||
@@ -190,9 +228,11 @@
|
||||
</div>
|
||||
|
||||
<div @class([
|
||||
'application-settings-section-body w-full',
|
||||
'application-settings-section-body relative w-full',
|
||||
'is-flush' => $backups->isNotEmpty() || $databaseBackups->isNotEmpty(),
|
||||
])>
|
||||
<x-table.loading target="openSchedule" text="Loading schedule..." />
|
||||
|
||||
<div x-cloak x-show="backups.length > 0 && filteredBackups.length === 0">
|
||||
<x-empty size="sm" title="No backups found"
|
||||
description="No scheduled backups match your search." />
|
||||
@@ -200,6 +240,7 @@
|
||||
|
||||
@if ($backups->isNotEmpty() || $databaseBackups->isNotEmpty())
|
||||
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
|
||||
<div class="min-w-[59rem]">
|
||||
<div class="data-table-header backup-table-grid service-backup-table-grid">
|
||||
<span>Target</span>
|
||||
<span>Type</span>
|
||||
@@ -207,6 +248,7 @@
|
||||
<span>Status</span>
|
||||
<span>S3</span>
|
||||
<span>Last run</span>
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
@foreach ($databaseBackups as $databaseBackup)
|
||||
@@ -227,16 +269,12 @@
|
||||
};
|
||||
$databaseBackupId = 'database:'.$databaseBackup->id;
|
||||
@endphp
|
||||
<a wire:key="database-backup-{{ $databaseBackup->uuid }}"
|
||||
<div wire:key="database-backup-{{ $databaseBackup->uuid }}"
|
||||
x-show="isVisible(@js($databaseBackupId))"
|
||||
x-bind:style="{ order: backupOrder(@js($databaseBackupId)) }"
|
||||
href="{{ route('project.service.database.backup.show', [
|
||||
...$parameters,
|
||||
'stack_service_uuid' => $databaseBackup->database->uuid,
|
||||
'backup_uuid' => $databaseBackup->uuid,
|
||||
]) }}"
|
||||
{{ wireNavigate() }}
|
||||
class="data-table-row backup-table-grid text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim">
|
||||
wire:click="openSchedule('{{ $databaseBackup->uuid }}')"
|
||||
wire:keydown.enter="openSchedule('{{ $databaseBackup->uuid }}')" role="button" tabindex="0"
|
||||
class="data-table-row backup-table-grid cursor-pointer text-left text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim">
|
||||
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg">
|
||||
{{ $databaseBackup->database->human_name ?: $databaseBackup->database->name }}
|
||||
</span>
|
||||
@@ -248,7 +286,12 @@
|
||||
:type="$databaseBackup->save_s3 ? ($databaseBackup->s3 ? 'success' : 'error') : 'neutral'" />
|
||||
</span>
|
||||
<span>{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}</span>
|
||||
</a>
|
||||
<span class="flex justify-end">
|
||||
<x-forms.button type="button" canGate="update" :canResource="$service"
|
||||
wire:click.stop="backupNow('database', '{{ $databaseBackup->uuid }}')"
|
||||
wire:target="backupNow('database', '{{ $databaseBackup->uuid }}')">Back up now</x-forms.button>
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@foreach ($backups as $backup)
|
||||
@@ -268,12 +311,12 @@
|
||||
default => 'neutral',
|
||||
};
|
||||
@endphp
|
||||
<a wire:key="volume-backup-{{ $backup->uuid }}"
|
||||
<div wire:key="volume-backup-{{ $backup->uuid }}"
|
||||
x-show="isVisible(@js('storage:'.$backup->id))"
|
||||
x-bind:style="{ order: backupOrder(@js('storage:'.$backup->id)) }"
|
||||
href="{{ route('project.service.volume-backups.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}"
|
||||
{{ wireNavigate() }}
|
||||
class="data-table-row backup-table-grid text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim">
|
||||
wire:click="openSchedule('{{ $backup->uuid }}')"
|
||||
wire:keydown.enter="openSchedule('{{ $backup->uuid }}')" role="button" tabindex="0"
|
||||
class="data-table-row backup-table-grid cursor-pointer text-left text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim">
|
||||
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $backup->targetName() }}">
|
||||
{{ $backup->targetName() }}
|
||||
@@ -288,8 +331,14 @@
|
||||
<span>
|
||||
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
|
||||
</span>
|
||||
</a>
|
||||
<span class="flex justify-end">
|
||||
<x-forms.button type="button" canGate="update" :canResource="$service"
|
||||
wire:click.stop="backupNow('storage', '{{ $backup->uuid }}')"
|
||||
wire:target="backupNow('storage', '{{ $backup->uuid }}')">Back up now</x-forms.button>
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-empty size="sm" title="No scheduled backups"
|
||||
@@ -297,6 +346,8 @@
|
||||
icon-name="storages" />
|
||||
@endif
|
||||
</div>
|
||||
<livewire:project.service.backup-executions :service="$service"
|
||||
wire:key="service-backup-executions-{{ $service->id }}" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,9 +8,29 @@
|
||||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-backup-sidebar context="service-volume" :parameters="$parameters" :section="$section" />
|
||||
<x-service.configuration-sidebar :service="$service"
|
||||
current-route="project.service.volume-backups.index" />
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-6">
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<div>
|
||||
<a class="inline-flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-900 dark:text-fg-dim dark:hover:text-fg"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('project.service.volume-backups.index', collect($parameters)->except('backup_uuid')->all()) }}">
|
||||
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
|
||||
Back to backups
|
||||
</a>
|
||||
<h1 class="mt-2 text-xl font-semibold text-neutral-950 dark:text-fg">
|
||||
{{ $backup->targetName() }} backup
|
||||
</h1>
|
||||
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $backup->frequency }} schedule
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<x-backup-tabs context="service-volume" :parameters="$parameters" :section="$section" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<livewire:project.shared.storages.volume-backups :storage="$backup->backupable"
|
||||
:resource="$service" :section="$section"
|
||||
wire:key="service-volume-backup-{{ $backup->uuid }}-{{ $section }}" />
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<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-application.restart-limit-warning :application="$resource" />
|
||||
<x-status-summary :status="$resource->status" />
|
||||
@if ($hasAdditionalDestinations)
|
||||
<x-forms.button canGate="deploy" :canResource="$resource"
|
||||
@@ -217,6 +218,9 @@
|
||||
<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 (method_exists($resource, 'stoppedAfterRestartLimit'))
|
||||
<x-application.restart-limit-warning :application="$resource" />
|
||||
@endif
|
||||
@if ($primaryStatus->startsWith('running'))
|
||||
<x-status.running :status="$primaryStatus->value()" />
|
||||
@elseif ($primaryStatus->startsWith(['starting', 'restarting']))
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
<template x-if="!isMultiline">
|
||||
<div wire:key="env-value-input">
|
||||
<x-forms.env-var-input placeholder="production" id="value" label="Value" required
|
||||
canGate="manageEnvironment" :canResource="$resource"
|
||||
:availableVars="$shared ? [] : $this->availableSharedVariables"
|
||||
:hasVaultSource="$this->hasSecretManagerSource()"
|
||||
:projectUuid="data_get($parameters, 'project_uuid')"
|
||||
:environmentUuid="data_get($parameters, 'environment_uuid')"
|
||||
:serverUuid="data_get($parameters, 'server_uuid')" />
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
Add
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:project.shared.environment-variable.add />
|
||||
<livewire:project.shared.environment-variable.add :resource="$resource" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
</x-table.toolbar>
|
||||
|
||||
@@ -152,14 +152,14 @@
|
||||
</label>
|
||||
<div class="relative">
|
||||
@if (!$valuesLoaded)
|
||||
<div class="input input-with-password-toggle flex w-full items-center text-neutral-500 dark:text-fg-dim"
|
||||
aria-busy="true">
|
||||
<x-loading text="Loading value..." />
|
||||
</div>
|
||||
<x-forms.input loading loadingText="Loading value..."
|
||||
defaultClass="input input-with-password-toggle" />
|
||||
@else
|
||||
<x-forms.env-var-input id="value" type="password"
|
||||
canGate="manageEnvironment" :canResource="$this->resource"
|
||||
:required="$is_redis_credential" :disabled="!$canEditValue"
|
||||
:availableVars="$isSharedVariable ? [] : $this->availableSharedVariables"
|
||||
:hasVaultSource="$this->hasSecretManagerSource()"
|
||||
:projectUuid="data_get($parameters, 'project_uuid')"
|
||||
:environmentUuid="data_get($parameters, 'environment_uuid')"
|
||||
:serverUuid="data_get($parameters, 'server_uuid')" />
|
||||
|
||||
@@ -513,19 +513,23 @@
|
||||
@if ($outputs)
|
||||
@php
|
||||
$displayLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== '');
|
||||
$lineOccurrences = [];
|
||||
@endphp
|
||||
<div id="logs" class="font-logs max-w-full cursor-default text-[11px] leading-relaxed sm:text-xs">
|
||||
<div x-show="searchQuery.trim() && matchCount === 0"
|
||||
class="py-2 text-gray-500 dark:text-gray-400">
|
||||
No matches found.
|
||||
</div>
|
||||
@foreach ($displayLines as $index => $line)
|
||||
@foreach ($displayLines as $line)
|
||||
@php
|
||||
$lineFingerprint = md5($line);
|
||||
$lineOccurrence = $lineOccurrences[$lineFingerprint] ?? 0;
|
||||
$lineOccurrences[$lineFingerprint] = $lineOccurrence + 1;
|
||||
|
||||
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
|
||||
$timestamp = '';
|
||||
$logContent = $line;
|
||||
if (preg_match('/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z?\s(.*)$/', $line, $matches)) {
|
||||
$microseconds = isset($matches[2]) ? substr($matches[2], 0, 6) : '000000';
|
||||
$logContent = $matches[3];
|
||||
|
||||
// Convert UTC Docker timestamp to server timezone for display
|
||||
@@ -537,11 +541,9 @@
|
||||
// keep UTC
|
||||
}
|
||||
$timestamp = $carbonTs->format('Y-M-d H:i:s');
|
||||
// Include microseconds in key for uniqueness
|
||||
$lineKey = "{$timestamp}.{$microseconds}";
|
||||
}
|
||||
@endphp
|
||||
<div wire:key="{{ $lineKey ?? 'line-' . $index }}" data-log-line data-log-content="{{ $line }}" class="log-line logs-viewer-line">
|
||||
<div wire:key="log-{{ $lineFingerprint }}-{{ $lineOccurrence }}" data-log-line data-log-content="{{ $line }}" class="log-line logs-viewer-line">
|
||||
@if ($timestamp && $showTimeStamps)
|
||||
<span class="logs-viewer-timestamp text-gray-500">{{ $timestamp }}</span>
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<div class="mt-8">
|
||||
@php
|
||||
$secretManagerDescription = 'Reference remote secrets in your environment variables with {{vault.KEY}}. Values are fetched at deployment time and are never stored in the Coolify database. Changing the source does not re-check existing references — missing keys fail the next deployment.';
|
||||
$removeSourceWarning = 'Existing {{vault.*}} reference variables will fail the next deployment until they are removed too.';
|
||||
@endphp
|
||||
<x-application.settings-section title="Secret manager" :description="$secretManagerDescription">
|
||||
|
||||
@if (! $link && $availableTokens->isEmpty())
|
||||
<x-empty title="No secret manager tokens"
|
||||
description="Add a Doppler, Infisical, or HashiCorp Vault token under Keys & Tokens > Integration Tokens first."
|
||||
icon-name="keys" size="sm" />
|
||||
@else
|
||||
@can('update', $resource)
|
||||
<div class="application-settings-form flex w-full flex-col gap-4">
|
||||
<div class="flex items-end gap-2">
|
||||
<x-forms.listbox id="integration_token_uuid" label="Integration token" :live="true"
|
||||
placeholder="Select a secret manager token" :options="$availableTokens
|
||||
->map(fn ($token) => [
|
||||
'value' => $token->uuid,
|
||||
'label' => $token->name.' ('.$token->providerName().')',
|
||||
])
|
||||
->all()" />
|
||||
@if ($link)
|
||||
<x-modal-confirmation title="Remove secret manager source?" isErrorButton
|
||||
buttonTitle="Remove" submitAction="removeSource"
|
||||
:actions="[$removeSourceWarning]"
|
||||
:confirmWithText="false" :confirmWithPassword="false"
|
||||
step1ButtonText="Remove source" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($link)
|
||||
@if ($selectedToken?->provider === 'doppler')
|
||||
@if ($selectedToken->dopplerTokenType() === 'service_account')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="settings.project" label="Project (required)"
|
||||
wire:blur="saveSettings" />
|
||||
<x-forms.input required id="settings.config" label="Config (required)" placeholder="prd"
|
||||
wire:blur="saveSettings" />
|
||||
</div>
|
||||
@else
|
||||
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
Project and config are fixed by this service token.
|
||||
</p>
|
||||
@endif
|
||||
@elseif ($selectedToken?->provider === 'infisical')
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<x-forms.input required id="settings.project_id" label="Project ID"
|
||||
wire:blur="saveSettings" />
|
||||
<x-forms.input required id="settings.environment" label="Environment slug"
|
||||
placeholder="prod" wire:blur="saveSettings" />
|
||||
<x-forms.input id="settings.secret_path" label="Secret path" placeholder="/"
|
||||
wire:blur="saveSettings" />
|
||||
</div>
|
||||
@elseif ($selectedToken?->provider === 'vault')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="settings.mount" label="KV v2 mount" placeholder="secret"
|
||||
wire:blur="saveSettings" />
|
||||
<x-forms.input required id="settings.path" label="Secret path"
|
||||
placeholder="my-app/production" wire:blur="saveSettings" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-forms.button wire:click="loadKeys" wire:target="loadKeys">
|
||||
{{ $keysLoaded ? 'Reload keys' : 'Browse keys' }}
|
||||
</x-forms.button>
|
||||
@if ($keysLoaded)
|
||||
<x-forms.button wire:click="importAll" wire:target="importAll" isHighlighted>
|
||||
Import all keys
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($keysLoaded)
|
||||
@if (count($keys) === 0)
|
||||
<div class="text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
No secrets found at this source.
|
||||
</div>
|
||||
@else
|
||||
<x-forms.input label="Search keys" placeholder="Filter key names"
|
||||
wire:model.live.debounce.300ms="search" />
|
||||
<div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 dark:divide-white/[0.07] dark:border-white/[0.08]">
|
||||
@forelse ($filteredKeys as $key)
|
||||
<div wire:key="secret-key-{{ $key }}"
|
||||
class="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="font-mono text-[12px] text-black dark:text-fg">{{ $key }}</span>
|
||||
<span class="font-mono text-[11px] text-neutral-400 dark:text-fg-dim">{{ '{{vault.'.$key.'}'.'}' }}</span>
|
||||
</div>
|
||||
<x-forms.button wire:click="addReference({{ \Illuminate\Support\Js::from($key) }})"
|
||||
wire:target="addReference({{ \Illuminate\Support\Js::from($key) }})">
|
||||
Add as variable
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-3 py-2 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
No keys match the search.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
<p class="text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Key names only — values stay in the secret manager. "Add as variable" creates
|
||||
<span class="font-mono">KEY={{ '{{vault.KEY}'.'}' }}</span>; you can also paste a reference
|
||||
into any variable value, including inside a longer string.
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@if ($link)
|
||||
<div class="px-1 py-2 text-[13px] text-black dark:text-fg">
|
||||
{{ $link->integrationToken->providerName() }}
|
||||
<span class="text-neutral-500 dark:text-fg-dim">
|
||||
{{ $link->integrationToken->name }} · {{ $link->sourceSummary() }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@@ -183,7 +183,7 @@
|
||||
@if ($supportsPreviewSuffix)
|
||||
<div class="volumes-col-pr min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
|
||||
<x-forms.listbox id="forms.{{ $id }}.isPreviewSuffixEnabled" :options="[
|
||||
<x-forms.listbox id="forms.{{ $id }}.isPreviewSuffixEnabled" portal :options="[
|
||||
['value' => true, 'label' => 'Add suffix'],
|
||||
['value' => false, 'label' => 'Share volume'],
|
||||
]" />
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
@php
|
||||
$showActionsColumn = $resource instanceof \App\Models\Application;
|
||||
$gridClass = match (true) {
|
||||
$supportsPreviewSuffix => 'volumes-table-grid-with-pr',
|
||||
$showActionsColumn => 'volumes-table-grid',
|
||||
default => 'volumes-table-grid-readonly',
|
||||
};
|
||||
$canUpdate = auth()->user()?->can('update', $resource) ?? false;
|
||||
$inputsReadonly = $isReadOnly || ! $canUpdate;
|
||||
$displayHostPath = filled($hostPath) ? $hostPath : '—';
|
||||
@endphp
|
||||
|
||||
@if ($inputsReadonly)
|
||||
{{-- Read-only: plain data-table row (service / compose / no permission) --}}
|
||||
<div class="env-table-item" wire:key="storage-row-{{ $storage->id }}">
|
||||
<div class="data-table-row {{ $gridClass }} text-[13px] text-neutral-700 dark:text-fg-dim">
|
||||
<div class="volumes-cell-name min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 truncate text-[13px] font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $name }}">{{ $name }}</span>
|
||||
@if ($hasEnabledBackup)
|
||||
@if ($backupUrl)
|
||||
<a href="{{ $backupUrl }}"
|
||||
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
|
||||
title="Volume backup is enabled">
|
||||
Backup
|
||||
</a>
|
||||
@else
|
||||
<span class="table-badge table-badge-success shrink-0" title="Volume backup is enabled">
|
||||
Backup
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="volumes-col-source min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
|
||||
<span class="block min-w-0 truncate text-[13px]" title="{{ $hostPath }}">
|
||||
{{ $displayHostPath }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="volumes-cell-dest min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
|
||||
<span class="block min-w-0 truncate text-[13px] text-neutral-950 dark:text-fg"
|
||||
title="{{ $mountPath }}">{{ $mountPath }}</span>
|
||||
</div>
|
||||
|
||||
@if ($supportsPreviewSuffix)
|
||||
<div class="volumes-col-pr min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
|
||||
<span>{{ $isPreviewSuffixEnabled ? 'Add suffix' : 'Share volume' }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($showActionsColumn)
|
||||
<div class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
|
||||
@if ($canUpdate)
|
||||
@if ($showBackupModal)
|
||||
<x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
|
||||
wireOpen="showBackupModal">
|
||||
<livewire:project.application.backup.create :application="$resource"
|
||||
:selected-target-key="'volume:' . $storage->id"
|
||||
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
|
||||
</x-modal-input>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
|
||||
Backup
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@else
|
||||
<span class="text-neutral-400 dark:text-fg-faint">—</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Editable volume row --}}
|
||||
<form wire:submit="submit" class="env-table-item" wire:key="storage-row-{{ $storage->id }}">
|
||||
<div class="data-table-row {{ $gridClass }}">
|
||||
<div class="volumes-cell-name min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<x-forms.input id="name" required />
|
||||
</div>
|
||||
@if ($hasEnabledBackup)
|
||||
@if ($backupUrl)
|
||||
<a href="{{ $backupUrl }}"
|
||||
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
|
||||
title="Volume backup is enabled">
|
||||
Backup
|
||||
</a>
|
||||
@else
|
||||
<span class="table-badge table-badge-success shrink-0" title="Volume backup is enabled">
|
||||
Backup
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="volumes-col-source min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
|
||||
<x-forms.input id="hostPath" placeholder="Host path (optional)" />
|
||||
</div>
|
||||
|
||||
<div class="volumes-cell-dest min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
|
||||
<x-forms.input id="mountPath" required placeholder="/path/in/container" />
|
||||
</div>
|
||||
|
||||
@if ($supportsPreviewSuffix)
|
||||
<div class="volumes-col-pr min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
|
||||
<x-forms.listbox id="isPreviewSuffixEnabled" onChange="instantSave" :options="[
|
||||
['value' => true, 'label' => 'Add suffix'],
|
||||
['value' => false, 'label' => 'Share volume'],
|
||||
]" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
|
||||
<x-forms.button type="submit" class="!px-2.5 !text-xs">
|
||||
Update
|
||||
</x-forms.button>
|
||||
|
||||
@if ($resource instanceof \App\Models\Application)
|
||||
@if ($showBackupModal)
|
||||
<x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
|
||||
wireOpen="showBackupModal">
|
||||
<livewire:project.application.backup.create :application="$resource"
|
||||
:selected-target-key="'volume:' . $storage->id"
|
||||
wire:key="configure-volume-backup-{{ $storage->id }}" />
|
||||
</x-modal-input>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
|
||||
Backup
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="[
|
||||
'The selected persistent storage/volume will be permanently deleted.',
|
||||
'If the persistent storage/volume is actvily used by a resource data will be lost.',
|
||||
]" confirmationText="{{ $storage->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
|
||||
shortConfirmationLabel="Storage Name" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
@@ -2,29 +2,52 @@
|
||||
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="save">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name" />
|
||||
<x-forms.input readonly label="Provider" value="Cloudflare" />
|
||||
<x-forms.input readonly label="Provider" value="{{ $integrationToken->providerName() }}" />
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input type="password" id="newToken" label="New API token"
|
||||
<x-forms.input type="password" id="newToken"
|
||||
label="{{ $integrationToken->provider === 'infisical' ? 'New client secret' : 'New API token' }}"
|
||||
placeholder="Leave blank to keep the current token"
|
||||
helper="Paste a replacement token to rotate this credential." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="edit-dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
@if ($integrationToken->provider === 'infisical')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="metadata.base_url" label="Base URL"
|
||||
placeholder="https://app.infisical.com" />
|
||||
<x-forms.input required id="metadata.client_id" label="Client ID" />
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@elseif ($integrationToken->provider === 'vault')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="metadata.base_url" label="Base URL"
|
||||
placeholder="https://vault.example.com:8200" />
|
||||
<x-forms.input id="metadata.namespace" label="Namespace (optional)" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (in_array('dns', $capabilities, true))
|
||||
@if ($integrationToken->provider === 'cloudflare')
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="edit-dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@else
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
|
||||
<p>Coolify reads secrets from this provider at deployment time. Secret values are never stored in
|
||||
the Coolify database.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($integrationToken->provider === 'cloudflare' && in_array('dns', $capabilities, true))
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Required Cloudflare permissions</div>
|
||||
<ul class="list-inside list-disc">
|
||||
|
||||
@@ -1,30 +1,65 @@
|
||||
<div class="w-full">
|
||||
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="addToken">
|
||||
<x-forms.listbox required id="provider" label="Provider" :options="[
|
||||
<x-forms.listbox required id="provider" label="Provider" :live="true" :options="[
|
||||
['value' => 'cloudflare', 'label' => 'Cloudflare'],
|
||||
['value' => 'doppler', 'label' => 'Doppler'],
|
||||
['value' => 'infisical', 'label' => 'Infisical'],
|
||||
['value' => 'vault', 'label' => 'HashiCorp Vault'],
|
||||
]" />
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name" placeholder="Production DNS" />
|
||||
<x-forms.input required type="password" id="token" label="API token"
|
||||
placeholder="Paste the provider token" />
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
@if ($provider === 'infisical')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name" placeholder="Production secrets" />
|
||||
<x-forms.input required id="metadata.client_id" label="Client ID"
|
||||
placeholder="Machine identity client ID" />
|
||||
<x-forms.input required type="password" id="token" label="Client secret"
|
||||
placeholder="Paste the machine identity client secret" />
|
||||
<x-forms.input required id="metadata.base_url" label="Base URL"
|
||||
placeholder="https://app.infisical.com"
|
||||
helper="Use the URL of your self-hosted Infisical instance, or the Infisical cloud URL." />
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@else
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name"
|
||||
placeholder="{{ $provider === 'cloudflare' ? 'Production DNS' : 'Production secrets' }}" />
|
||||
<x-forms.input required type="password" id="token"
|
||||
label="{{ $provider === 'vault' ? 'Vault token' : 'API token' }}"
|
||||
placeholder="Paste the provider token" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (in_array('dns', $capabilities, true))
|
||||
@if ($provider === 'vault')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="metadata.base_url" label="Base URL"
|
||||
placeholder="https://vault.example.com:8200" />
|
||||
<x-forms.input id="metadata.namespace" label="Namespace (optional)"
|
||||
placeholder="admin/team-a" helper="Only for Vault Enterprise / HCP Vault." />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($provider === 'cloudflare')
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@else
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
|
||||
<p>Coolify reads secrets from this provider at deployment time and writes them into the generated
|
||||
<code>.env</code> file. Secret values are never stored in the Coolify database.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($provider === 'cloudflare' && in_array('dns', $capabilities, true))
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Required Cloudflare permissions</div>
|
||||
<ul class="list-inside list-disc">
|
||||
@@ -38,6 +73,24 @@
|
||||
Create this token in Cloudflare
|
||||
</a>
|
||||
</div>
|
||||
@elseif ($provider === 'doppler')
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Recommended Doppler token</div>
|
||||
<p>Use a read-only <span class="font-medium">Service Token</span> (dp.st.*). It is pinned to one
|
||||
project and config. Create it in Doppler under Project > Config > Access.</p>
|
||||
</div>
|
||||
@elseif ($provider === 'infisical')
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Infisical machine identity</div>
|
||||
<p>Create a machine identity with Universal Auth and read access to your project. Paste the client
|
||||
ID above and the client secret in the secret field.</p>
|
||||
</div>
|
||||
@elseif ($provider === 'vault')
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Vault token</div>
|
||||
<p>Use a token with read access to your KV v2 secrets. Prefer a periodic or long-lived token —
|
||||
deployments fail when the token expires.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<x-security.settings-layout>
|
||||
<div class="application-settings-form">
|
||||
<x-application.settings-section title="Integration tokens"
|
||||
description="Credentials used by third-party integrations such as DNS providers." flush>
|
||||
description="Credentials used by third-party integrations such as DNS providers and secret managers." flush>
|
||||
<x-slot:actions>
|
||||
@can('create', App\Models\IntegrationToken::class)
|
||||
<x-modal-input title="New Integration Token">
|
||||
@@ -56,7 +56,7 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="text-center text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ ucfirst($savedToken->provider) }}
|
||||
{{ $savedToken->providerName() }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="capability in tokenCapabilities" :key="capability">
|
||||
|
||||
@@ -76,9 +76,8 @@
|
||||
</div>
|
||||
@foreach ($privateKeys as $key)
|
||||
@can('view', $key)
|
||||
<x-modal-input title="Edit Private Key" isFullWidth :wireIgnore="false" :contentClicks="false"
|
||||
<div wire:key="private-key-{{ $key->id }}"
|
||||
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<x-slot:content>
|
||||
<div
|
||||
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:hover:bg-white/[0.025]"
|
||||
>
|
||||
@@ -104,14 +103,13 @@
|
||||
@endif
|
||||
</div>
|
||||
<button type="button" class="icon-button" title="Edit private key"
|
||||
aria-label="Edit {{ $key->name }}" @click="modalOpen=true">
|
||||
aria-label="Edit {{ $key->name }}"
|
||||
@click="$dispatch('open-private-key-editor', { name: @js($key->name), description: @js($key->description ?? '') })"
|
||||
wire:click="openEditor('{{ $key->uuid }}')">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:security.private-key.show :private_key_uuid="$key->uuid" :modalMode="true"
|
||||
:key="'private-key-editor-'.$key->uuid" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid min-h-14 cursor-not-allowed grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 opacity-65 last:border-b-0 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:border-white/[0.07]"
|
||||
title="You do not have permission to view this private key">
|
||||
@@ -139,6 +137,39 @@
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-modal-input title="Edit Private Key" :wireIgnore="false" :contentClicks="false"
|
||||
@open-private-key-editor.window="modalOpen=true; $nextTick(() => { $refs.loadingPrivateKeyName.value = $event.detail.name; $refs.loadingPrivateKeyDescription.value = $event.detail.description })">
|
||||
<x-slot:content><span class="hidden" aria-hidden="true"></span></x-slot:content>
|
||||
<div wire:loading.flex wire:target="openEditor" aria-label="Loading private key editor"
|
||||
class="w-full flex-col gap-4">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input label="Name" required x-ref="loadingPrivateKeyName" />
|
||||
<x-forms.input label="Description" x-ref="loadingPrivateKeyDescription" />
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input label="Public key" loading
|
||||
helper="Copy this value to ~/.ssh/authorized_keys on the target server." />
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<div class="mb-1.5 flex items-center justify-between gap-3">
|
||||
<label class="text-[13px] font-medium">Private key <span class="text-helper">*</span></label>
|
||||
<span class="text-[11px] font-medium text-neutral-400 dark:text-fg-faint">Edit key</span>
|
||||
</div>
|
||||
<x-forms.input loading :allowToPeak="false" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button disabled isError>Delete</x-forms.button>
|
||||
<x-forms.button disabled isHighlighted>Save changes</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
@if ($selectedPrivateKeyUuid)
|
||||
<div wire:loading.remove wire:target="openEditor">
|
||||
<livewire:security.private-key.show :private_key_uuid="$selectedPrivateKeyUuid" :modalMode="true"
|
||||
:key="'private-key-editor-'.$selectedPrivateKeyUuid" />
|
||||
</div>
|
||||
@endif
|
||||
</x-modal-input>
|
||||
</x-application.settings-section>
|
||||
|
||||
</x-security.settings-layout>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<div x-init="$wire.loadPublicKey()">
|
||||
<div>
|
||||
@if ($modalMode)
|
||||
<form wire:submit="changePrivateKey" class="flex flex-col gap-4" x-data="{ showPrivateKey: false }">
|
||||
<div class="relative">
|
||||
<form wire:submit="changePrivateKey" class="flex flex-col gap-4" x-data="{ showPrivateKey: false }"
|
||||
wire:loading.class="pointer-events-none opacity-50" wire:target="delete">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input canGate="update" :canResource="$private_key" id="name" label="Name" required />
|
||||
<x-forms.input canGate="update" :canResource="$private_key" id="description" label="Description" />
|
||||
@@ -34,6 +36,11 @@
|
||||
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
<div wire:loading.flex wire:target="delete"
|
||||
class="absolute inset-0 z-10 items-center justify-center rounded-lg bg-white/50 dark:bg-black/40">
|
||||
<x-loading text="Deleting private key..." />
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-slot:title>
|
||||
{{ $private_key->name }} | Private Keys | Coolify
|
||||
|
||||
@@ -156,8 +156,17 @@
|
||||
class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
{{ $server->name }}
|
||||
</h1>
|
||||
<x-server.status-summary :server="$server" :proxy-status="$proxyStatus"
|
||||
:show-sentinel-status="$showSentinelStatus" />
|
||||
<div class="flex w-full min-w-0 items-center gap-2">
|
||||
<x-server.status-summary :server="$server" :proxy-status="$proxyStatus"
|
||||
:show-sentinel-status="$showSentinelStatus" />
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a target="_blank" href="http://{{ $serverIp }}:8080" title="Open Traefik dashboard"
|
||||
class="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 text-xs font-medium leading-none text-neutral-700 dark:border-white/[0.12] dark:bg-white/[0.07] dark:text-white">
|
||||
<x-reicon name="external-link" class="size-3" />
|
||||
Traefik
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -165,66 +174,31 @@
|
||||
<div class="w-full xl:hidden">
|
||||
@if ($server->proxySet())
|
||||
@can('manageProxy', $server)
|
||||
<div id="server-mobile-actions" class="relative mb-3"
|
||||
x-data="{ open: false }" @click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<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">
|
||||
Actions
|
||||
</span>
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.left
|
||||
class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="menu">
|
||||
@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">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
|
||||
</span>
|
||||
Restart Proxy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
</span>
|
||||
Stop Proxy
|
||||
</button>
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a class="listbox-option justify-start! gap-2.5!" target="_blank"
|
||||
href="http://{{ $serverIp }}:8080" @click="open = false" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="external-link" class="size-3! opacity-70" />
|
||||
</span>
|
||||
Traefik Dashboard
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; $wire.dispatch('checkProxyEvent')" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
</span>
|
||||
Start Proxy
|
||||
</button>
|
||||
@endif
|
||||
<x-split-action id="server-mobile-actions" class="mb-3 flex w-full">
|
||||
@if ($proxyCanBeStopped)
|
||||
<x-slot:main wire:loading.attr="disabled" wire:target="checkProxy,startProxy"
|
||||
@click="document.getElementById('server-mobile-restart-proxy-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5" />
|
||||
Restart Proxy
|
||||
</x-slot:main>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="checkProxyStatus" wire:loading.attr="disabled"
|
||||
@click="open = false" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Refresh Proxy Status
|
||||
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop Proxy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-slot:main wire:loading.attr="disabled" wire:target="checkProxy,startProxy" @click="$wire.dispatch('checkProxyEvent')">
|
||||
<x-reicon name="play-circle" class="size-3.5" />
|
||||
Start Proxy
|
||||
</x-slot:main>
|
||||
@endif
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" wire:click="checkProxyStatus"
|
||||
wire:loading.attr="disabled" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Refresh Proxy Status
|
||||
</button>
|
||||
</x-split-action>
|
||||
|
||||
{{-- Programmatic open only (clicked from the Actions menu). Keep fully
|
||||
display:none so the modal shells never reserve a layout row. --}}
|
||||
@@ -299,66 +273,40 @@
|
||||
|
||||
@if ($server->proxySet())
|
||||
@can('manageProxy', $server)
|
||||
<div id="server-desktop-actions" class="resource-heading-actions relative shrink-0"
|
||||
x-data="{ open: false }" x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<button type="button" class="button button-highlighted" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu" wire:loading.attr="disabled" wire:loading.class="is-loading"
|
||||
wire:target="checkProxy,startProxy">
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
@if ($proxyCanBeStopped)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Restart Proxy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
</span>
|
||||
Stop Proxy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; $wire.dispatch('checkProxyEvent')" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Start Proxy
|
||||
</button>
|
||||
@endif
|
||||
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300"
|
||||
role="separator"></div>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="checkProxyStatus" wire:loading.attr="disabled"
|
||||
@click="open = false" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Refresh Proxy Status
|
||||
</button>
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a class="listbox-option justify-start! gap-2.5!" target="_blank"
|
||||
href="http://{{ $serverIp }}:8080" @click="open = false" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="external-link" class="size-3! opacity-70" />
|
||||
</span>
|
||||
Traefik Dashboard
|
||||
</a>
|
||||
@endif
|
||||
@if ($traefikDashboardAvailable)
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<a class="app-tab shrink-0 gap-1" target="_blank" title="Open Traefik dashboard"
|
||||
href="http://{{ $serverIp }}:8080">
|
||||
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
|
||||
Traefik Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<x-split-action id="server-desktop-actions" class="resource-heading-actions shrink-0">
|
||||
@if ($proxyCanBeStopped)
|
||||
<x-slot:main wire:loading.attr="disabled" wire:target="checkProxy,startProxy"
|
||||
@click="document.getElementById('server-mobile-restart-proxy-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5" />
|
||||
Restart Proxy
|
||||
</x-slot:main>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop Proxy
|
||||
</button>
|
||||
@else
|
||||
<x-slot:main wire:loading.attr="disabled" wire:target="checkProxy,startProxy" @click="$wire.dispatch('checkProxyEvent')">
|
||||
<x-reicon name="play-circle" class="size-3.5" />
|
||||
Start Proxy
|
||||
</x-slot:main>
|
||||
@endif
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" wire:click="checkProxyStatus"
|
||||
wire:loading.attr="disabled" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Refresh Proxy Status
|
||||
</button>
|
||||
</x-split-action>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<livewire:server.proxy.dynamic-configuration-navbar
|
||||
:server_id="$server->id" :server="$server" :fileName="$fileName"
|
||||
:value="$value ?? ''" :newFile="false"
|
||||
wire:key="{{ $fileName }}-{{ $loop->index }}" />
|
||||
wire:key="proxy-navbar-{{ $fileName }}" />
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
<x-forms.textarea disabled wire:model="contents.{{ $fileName }}"
|
||||
|
||||
@@ -61,10 +61,14 @@
|
||||
{{ str($resource->type())->headline() }}
|
||||
</div>
|
||||
<div>
|
||||
<x-status-badge :status="str($resourceStatus)->headline()"
|
||||
:type="str($resourceStatus)->contains('running')
|
||||
? 'success'
|
||||
: (str($resourceStatus)->contains(['failed', 'exited']) ? 'error' : 'neutral')" />
|
||||
@if (method_exists($resource, 'stoppedAfterRestartLimit') && $resource->stoppedAfterRestartLimit())
|
||||
<x-application.restart-limit-warning :application="$resource" />
|
||||
@else
|
||||
<x-status-badge :status="str($resourceStatus)->headline()"
|
||||
:type="str($resourceStatus)->contains('running')
|
||||
? 'success'
|
||||
: (str($resourceStatus)->contains(['failed', 'exited']) ? 'error' : 'neutral')" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@@ -134,11 +134,18 @@
|
||||
['value' => false, 'label' => 'Enabled'],
|
||||
['value' => true, 'label' => 'Disabled'],
|
||||
]" />
|
||||
<x-forms.listbox id="is_sponsorship_popup_enabled" label="Sponsorship reminders"
|
||||
helper="Show the monthly project sponsorship reminder." onChange="instantSave" :options="[
|
||||
['value' => true, 'label' => 'Enabled'],
|
||||
['value' => false, 'label' => 'Disabled'],
|
||||
]" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.listbox id="is_sponsorship_popup_enabled" label="Sponsorship reminders"
|
||||
helper="Show the monthly project sponsorship reminder." onChange="instantSave" :options="[
|
||||
['value' => true, 'label' => 'Enabled'],
|
||||
['value' => false, 'label' => 'Disabled'],
|
||||
]" />
|
||||
@if (isDev())
|
||||
<x-forms.button type="button" @click="$dispatch('show-sponsorship-reminder')">
|
||||
Show sponsorship reminder
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
<span>Skipped</span>
|
||||
</div>
|
||||
@foreach ($managerRuns as $run)
|
||||
<div wire:key="run-{{ $loop->index }}"
|
||||
<div wire:key="run-{{ md5(serialize($run)) }}"
|
||||
x-show="{{ $loop->index }} >= (page - 1) * perPage && {{ $loop->index }} < page * perPage"
|
||||
class="data-table-row scheduler-runs-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div class="font-mono text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
@@ -272,7 +272,7 @@
|
||||
default => ucfirst(str_replace('_', ' ', $skip['reason'])),
|
||||
};
|
||||
@endphp
|
||||
<div wire:key="skip-{{ $loop->index }}"
|
||||
<div wire:key="skip-{{ md5(serialize($skip)) }}"
|
||||
class="data-table-row skipped-jobs-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div class="font-mono text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $skip['timestamp'] }}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="flex items-start 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.04] dark:text-fg-dim">
|
||||
@if ($project->icon_path)
|
||||
<img src="{{ route('project.icon', ['project_uuid' => $project->uuid, 'v' => $project->updated_at->timestamp]) }}"
|
||||
<img src="{{ project_icon_url($project) }}"
|
||||
alt="{{ $project->name }} icon"
|
||||
class="h-full w-full rounded-lg object-cover">
|
||||
@else
|
||||
@@ -49,7 +49,7 @@
|
||||
href="{{ route('shared-variables.project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}
|
||||
class="flex min-h-14 items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
@if ($project->icon_path)
|
||||
<img src="{{ route('project.icon', ['project_uuid' => $project->uuid, 'v' => $project->updated_at->timestamp]) }}"
|
||||
<img src="{{ project_icon_url($project) }}"
|
||||
alt="{{ $project->name }} icon" class="size-4 shrink-0 rounded object-cover">
|
||||
@else
|
||||
<x-reicon name="projects" class="size-4 shrink-0 text-neutral-500 dark:text-fg-dim" />
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
Team Audit Log | Coolify
|
||||
</x-slot>
|
||||
|
||||
<x-team.settings-layout>
|
||||
<div class="application-settings-form">
|
||||
<x-application.settings-section title="Audit log"
|
||||
description="Activity from the last 90 days for the current team." flush>
|
||||
<div
|
||||
class="flex flex-col gap-2 border-b border-neutral-200 p-3 sm:flex-row sm:items-center dark:border-white/[0.08]">
|
||||
<div class="relative min-w-0 flex-1 sm:max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input wire:model.live.debounce.300ms="search" type="search"
|
||||
placeholder="Search activity" aria-label="Search activity"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:flex">
|
||||
<div class="sm:w-36">
|
||||
<x-forms.listbox id="action" live canGate="viewAdmin" :canResource="currentTeam()"
|
||||
:options="$actionOptions" />
|
||||
</div>
|
||||
<div class="sm:w-36">
|
||||
<x-forms.listbox id="source" live canGate="viewAdmin" :canResource="currentTeam()" :options="[
|
||||
['value' => 'all', 'label' => 'All sources'],
|
||||
['value' => 'ui', 'label' => 'Web UI'],
|
||||
['value' => 'api', 'label' => 'API'],
|
||||
['value' => 'mcp', 'label' => 'MCP'],
|
||||
['value' => 'webhook', 'label' => 'Webhook'],
|
||||
]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($events->isNotEmpty())
|
||||
<div class="overflow-x-auto">
|
||||
<div class="data-table transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
|
||||
wire:target="search,action,source,setPage,previousPage,nextPage">
|
||||
<div class="grid min-w-[760px] grid-cols-[14rem_minmax(0,1fr)_12rem_9rem] gap-4 border-b border-neutral-200 px-4 py-2 text-[11px] font-medium uppercase tracking-wide text-neutral-500 dark:border-white/[0.07] dark:text-fg-faint">
|
||||
<span>Actor</span>
|
||||
<span>Activity</span>
|
||||
<span>Source</span>
|
||||
<span class="text-right">Time</span>
|
||||
</div>
|
||||
@foreach ($events as $event)
|
||||
<div wire:key="audit-event-{{ $event->id }}"
|
||||
class="grid min-w-[760px] grid-cols-[14rem_minmax(0,1fr)_12rem_9rem] gap-4 border-b border-neutral-200 px-4 py-3 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
|
||||
{{ $event->actor_name ?: Str::headline($event->actor_type) }}
|
||||
</div>
|
||||
@if ($event->actor_email)
|
||||
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ $event->actor_email }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($event->actor_token_name)
|
||||
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint"
|
||||
title="Token: {{ $event->actor_token_name }}">
|
||||
Token: {{ $event->actor_token_name }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[13px] font-medium text-black dark:text-fg">
|
||||
{{ $event->description }}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ $event->event }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
<span class="rounded-md bg-neutral-100 px-2 py-1 dark:bg-white/[0.06]">
|
||||
{{ Str::upper($event->source) }}
|
||||
</span>
|
||||
<span>{{ Str::headline($event->action) }}</span>
|
||||
</div>
|
||||
<time datetime="{{ $event->created_at->toIso8601String() }}"
|
||||
title="{{ $event->created_at->toDayDateTimeString() }}"
|
||||
class="self-center text-right text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ $event->created_at->diffForHumans() }}
|
||||
</time>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-table-pagination :from="$events->firstItem() ?? 0" :to="$events->lastItem() ?? 0"
|
||||
:total="$events->total()" :current-page="$events->currentPage()"
|
||||
:last-page="$events->lastPage()" wire-target="setPage,previousPage,nextPage"
|
||||
previous-action="previousPage" next-action="nextPage">
|
||||
<x-slot:pageSize>
|
||||
<x-page-size-select model="perPage" livewire storage-key="coolify.page-size.audit-log"
|
||||
canGate="viewAdmin" :canResource="currentTeam()" />
|
||||
</x-slot:pageSize>
|
||||
</x-table-pagination>
|
||||
@else
|
||||
<x-empty title="No activity found"
|
||||
description="Team actions will appear here as they happen." icon-name="time-back" size="sm" />
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
</x-team.settings-layout>
|
||||
</div>
|
||||
@@ -1,4 +1,12 @@
|
||||
<div>
|
||||
@php
|
||||
$deletionBlockers = currentTeam()->deletionBlockers();
|
||||
$blockerDetails = [
|
||||
'projects' => ['label' => 'project', 'route' => 'project.index'],
|
||||
'servers' => ['label' => 'server', 'route' => 'server.index'],
|
||||
'sources' => ['label' => 'Git source', 'route' => 'source.all'],
|
||||
];
|
||||
@endphp
|
||||
<x-slot:title>
|
||||
Team Danger Zone | Coolify
|
||||
</x-slot>
|
||||
@@ -34,7 +42,7 @@
|
||||
{{ wireNavigate() }} href="{{ route('subscription.show') }}">subscription</a>
|
||||
before deleting this team.
|
||||
</p>
|
||||
@elseif(currentTeam()->isEmpty())
|
||||
@elseif($deletionBlockers === [])
|
||||
<p class="mt-2 max-w-2xl text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
|
||||
Permanently delete <strong class="font-semibold text-black dark:text-fg">{{ currentTeam()->name }}</strong>
|
||||
from Coolify. This action cannot be undone.
|
||||
@@ -45,7 +53,20 @@
|
||||
</ul>
|
||||
@else
|
||||
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
|
||||
Remove or move every resource owned by this team before deleting it.
|
||||
This team still owns:
|
||||
</p>
|
||||
<ul class="mt-2 space-y-1 text-[13px] text-neutral-600 dark:text-fg-dim">
|
||||
@foreach ($deletionBlockers as $type => $count)
|
||||
<li>
|
||||
<a class="font-medium text-coollabs hover:underline dark:text-warning"
|
||||
{{ wireNavigate() }} href="{{ route($blockerDetails[$type]['route']) }}">
|
||||
{{ $count }} {{ str($blockerDetails[$type]['label'])->plural($count) }}
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
|
||||
Remove or move these resources before deleting the team.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@@ -57,14 +78,15 @@
|
||||
auth()->user()->teams()->count() > 1 &&
|
||||
!auth()->user()->currentTeam()->personal_team &&
|
||||
!currentTeam()->subscription &&
|
||||
currentTeam()->isEmpty())
|
||||
$deletionBlockers === [])
|
||||
<x-modal-confirmation title="Confirm Team Deletion?" buttonTitle="Delete team"
|
||||
isErrorButton submitAction="delete"
|
||||
:actions="['The current team will be permanently deleted from Coolify and the database.']"
|
||||
confirmationText="{{ currentTeam()->name }}"
|
||||
confirmationLabel="Enter the team name to confirm permanent deletion"
|
||||
shortConfirmationLabel="Team name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
step2ButtonText="Permanently Delete" canGate="delete"
|
||||
:canResource="$team" />
|
||||
@else
|
||||
<x-forms.button disabled tooltip="Resolve the requirements shown before deleting this team.">
|
||||
Delete team
|
||||
|
||||
Reference in New Issue
Block a user