mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 08:17:02 +00:00
Merge remote-tracking branch 'origin/next' into audit-policies
This commit is contained in:
commit
c175d3d282
259 files changed
+12779
-3172
No files matched your search
@@ -347,3 +347,12 @@
|
||||
@utility log-info {
|
||||
@apply bg-blue-500/10 dark:bg-blue-500/15;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.sidebar-collapsed .menu-item {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
+71
-18
@@ -42,6 +42,10 @@ export function initializeTerminalComponent() {
|
||||
maxHeartbeatMisses: 3,
|
||||
// Command buffering for race condition prevention
|
||||
pendingCommand: null,
|
||||
// Last successfully sent SSH command — replayed after a transient reconnect
|
||||
// so the PTY respawns automatically. Cleared on intentional terminations
|
||||
// (pty-exited, idle-timeout, unprocessable).
|
||||
lastSentCommand: null,
|
||||
// Resize handling
|
||||
resizeObserver: null,
|
||||
resizeTimeout: null,
|
||||
@@ -75,8 +79,6 @@ export function initializeTerminalComponent() {
|
||||
focusWhenReady();
|
||||
});
|
||||
|
||||
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
|
||||
|
||||
this.$watch('terminalActive', (active) => {
|
||||
if (!active && this.keepAliveInterval) {
|
||||
clearInterval(this.keepAliveInterval);
|
||||
@@ -150,8 +152,11 @@ export function initializeTerminalComponent() {
|
||||
},
|
||||
|
||||
clearAllTimers() {
|
||||
[this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
|
||||
.forEach(timer => timer && clearInterval(timer));
|
||||
if (this.keepAliveInterval) {
|
||||
clearInterval(this.keepAliveInterval);
|
||||
}
|
||||
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
|
||||
.forEach(timer => timer && clearTimeout(timer));
|
||||
this.keepAliveInterval = null;
|
||||
this.reconnectInterval = null;
|
||||
this.connectionTimeoutId = null;
|
||||
@@ -161,9 +166,17 @@ export function initializeTerminalComponent() {
|
||||
|
||||
resetTerminal() {
|
||||
if (this.term) {
|
||||
this.$wire.dispatch('error', 'Terminal websocket connection lost.');
|
||||
this.term.reset();
|
||||
this.term.clear();
|
||||
this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
|
||||
// Preserve scrollback so the user keeps the context of their previous
|
||||
// session. Print a visible marker so they know where the disconnect
|
||||
// happened. Old PTY shell state cannot be restored — this is purely
|
||||
// a visual carry-over.
|
||||
try {
|
||||
const stamp = new Date().toLocaleTimeString();
|
||||
this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`);
|
||||
} catch (_) {
|
||||
// ignore — terminal not ready to receive writes
|
||||
}
|
||||
this.pendingWrites = 0;
|
||||
this.paused = false;
|
||||
this.commandBuffer = '';
|
||||
@@ -276,10 +289,22 @@ export function initializeTerminalComponent() {
|
||||
this.connectionTimeoutId = null;
|
||||
}
|
||||
|
||||
// Flush any buffered command from before WebSocket was ready
|
||||
// Flush any buffered command from before WebSocket was ready, otherwise
|
||||
// replay the last command so a transient reconnect respawns the PTY
|
||||
// automatically without requiring the user to click Connect again.
|
||||
if (this.pendingCommand) {
|
||||
this.sendMessage(this.pendingCommand);
|
||||
this.pendingCommand = null;
|
||||
} else if (this.lastSentCommand) {
|
||||
logTerminal('log', '[Terminal] Replaying last command after reconnect.');
|
||||
this.sendMessage(this.lastSentCommand);
|
||||
}
|
||||
|
||||
// (Re)start application-level keepalive on every successful connect.
|
||||
// Server-side WebSocket protocol pings are the primary heartbeat; this
|
||||
// adds a JSON-level ping in case the server-side is older or restarting.
|
||||
if (!this.keepAliveInterval) {
|
||||
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
|
||||
}
|
||||
|
||||
// Start ping timeout monitoring
|
||||
@@ -354,6 +379,9 @@ export function initializeTerminalComponent() {
|
||||
sendMessage(message) {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
if (message && message.command) {
|
||||
this.lastSentCommand = message;
|
||||
}
|
||||
} else {
|
||||
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
|
||||
}
|
||||
@@ -368,8 +396,6 @@ export function initializeTerminalComponent() {
|
||||
},
|
||||
|
||||
handleSocketMessage(event) {
|
||||
logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
|
||||
|
||||
// Handle pong responses
|
||||
if (event.data === 'pong') {
|
||||
this.heartbeatMissed = 0;
|
||||
@@ -387,7 +413,15 @@ export function initializeTerminalComponent() {
|
||||
this.term.open(document.getElementById('terminal'));
|
||||
this.term._initialized = true;
|
||||
} else {
|
||||
this.term.reset();
|
||||
// Already initialized — this is a reconnect or a follow-up command.
|
||||
// Preserve scrollback so the user keeps context. Write a visible
|
||||
// separator so the new shell prompt is easy to spot.
|
||||
try {
|
||||
const stamp = new Date().toLocaleTimeString();
|
||||
this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`);
|
||||
} catch (_) {
|
||||
// ignore — fall through; xterm will render the new prompt anyway
|
||||
}
|
||||
}
|
||||
this.terminalActive = true;
|
||||
this.term.focus();
|
||||
@@ -415,6 +449,7 @@ export function initializeTerminalComponent() {
|
||||
} else if (event.data === 'unprocessable') {
|
||||
if (this.term) this.term.reset();
|
||||
this.terminalActive = false;
|
||||
this.lastSentCommand = null;
|
||||
this.message = '(sorry, something went wrong, please try again)';
|
||||
|
||||
// Notify parent component that terminal connection failed
|
||||
@@ -423,9 +458,19 @@ export function initializeTerminalComponent() {
|
||||
this.terminalActive = false;
|
||||
this.term.reset();
|
||||
this.commandBuffer = '';
|
||||
this.lastSentCommand = null;
|
||||
|
||||
// Notify parent component that terminal disconnected
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (event.data === 'idle-timeout') {
|
||||
this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
|
||||
this.terminalActive = false;
|
||||
if (this.term) {
|
||||
this.term.reset();
|
||||
}
|
||||
this.commandBuffer = '';
|
||||
this.lastSentCommand = null;
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (
|
||||
typeof event.data === 'string' &&
|
||||
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
|
||||
@@ -494,11 +539,6 @@ export function initializeTerminalComponent() {
|
||||
},
|
||||
|
||||
keepAlive() {
|
||||
// Skip keepalive when document is hidden to prevent unnecessary disconnects
|
||||
if (!this.isDocumentVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage({ ping: true });
|
||||
} else if (this.connectionState === 'disconnected') {
|
||||
@@ -524,10 +564,23 @@ export function initializeTerminalComponent() {
|
||||
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
|
||||
|
||||
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
// Send immediate ping to verify connection is still alive
|
||||
// Connection may be half-open after Cloudflare/proxy idle drop while hidden.
|
||||
// Probe with a short timeout (5s) instead of the default 35s — force a
|
||||
// reconnect quickly if no pong arrives so the user is not stuck typing
|
||||
// into a dead socket.
|
||||
this.heartbeatMissed = 0;
|
||||
this.sendMessage({ ping: true });
|
||||
this.resetPingTimeout();
|
||||
if (this.pingTimeoutId) {
|
||||
clearTimeout(this.pingTimeoutId);
|
||||
}
|
||||
this.pingTimeoutId = setTimeout(() => {
|
||||
logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.');
|
||||
try {
|
||||
this.socket.close(4000, 'Visibility-resume timeout');
|
||||
} catch (_) {
|
||||
// ignore — close handler will run on its own
|
||||
}
|
||||
}, 5000);
|
||||
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
|
||||
// Was connected before but now disconnected - attempt reconnection
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<span {{ $attributes->merge(['class' => 'inline-flex items-center']) }}>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-warning/15 text-warning border border-warning/30">
|
||||
Deprecated
|
||||
</span>
|
||||
</span>
|
||||
@@ -229,7 +229,7 @@
|
||||
@readonly($readonly)
|
||||
@if ($modelBinding !== 'null')
|
||||
wire:model="{{ $modelBinding }}"
|
||||
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
|
||||
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"
|
||||
@disabled($disabled)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div {{ $attributes->merge(['class' => 'group']) }}>
|
||||
<div x-data="{ open: false }" @click.stop="open = !open" @click.outside="open = false"
|
||||
{{ $attributes->merge(['class' => 'group']) }}>
|
||||
<div class="info-helper">
|
||||
@isset($icon)
|
||||
{{ $icon }}
|
||||
@@ -10,7 +11,7 @@
|
||||
@endisset
|
||||
|
||||
</div>
|
||||
<div class="info-helper-popup">
|
||||
<div class="info-helper-popup" :class="{ 'block': open }">
|
||||
<div class="p-4">
|
||||
{!! $helper !!}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
<nav class="flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base"
|
||||
<nav class="flex flex-col flex-1 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base"
|
||||
:class="collapsed ? 'lg:px-1 px-2 sidebar-collapsed' : 'px-2'"
|
||||
@mouseover="
|
||||
if (!collapsed) return;
|
||||
const el = $event.target.closest('.menu-item');
|
||||
if (!el) { tooltip.show = false; return; }
|
||||
const text = el.getAttribute('title') || el.getAttribute('aria-label') || '';
|
||||
if (!text) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
tooltip.text = text;
|
||||
tooltip.x = rect.right + 8;
|
||||
tooltip.y = rect.top + rect.height / 2;
|
||||
tooltip.show = true;
|
||||
"
|
||||
@mouseleave="tooltip.show = false"
|
||||
x-data="{
|
||||
tooltip: { text: '', x: 0, y: 0, show: false },
|
||||
switchWidth() {
|
||||
if (this.full === 'full') {
|
||||
localStorage.setItem('pageWidth', 'center');
|
||||
@@ -77,12 +92,22 @@
|
||||
}
|
||||
}
|
||||
}">
|
||||
<div class="flex lg:pt-6 pt-4 pb-4 pl-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex pt-4 pb-4 pl-2 items-start gap-2 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out motion-reduce:transition-none"
|
||||
:class="collapsed ? 'lg:flex-col lg:items-center lg:pl-0 lg:gap-3 lg:pt-8' : 'lg:pt-6'">
|
||||
<div class="flex flex-col w-full" :class="collapsed && 'lg:hidden'">
|
||||
<a href="/" {{ wireNavigate() }} class="text-2xl font-bold tracking-tight dark:text-white hover:opacity-80 transition-opacity">Coolify</a>
|
||||
<x-version />
|
||||
</div>
|
||||
<div>
|
||||
<div class="hidden flex-col items-center w-full gap-1"
|
||||
:class="collapsed && 'lg:flex'">
|
||||
<a href="/" {{ wireNavigate() }}
|
||||
class="hover:opacity-80 transition-opacity"
|
||||
title="Coolify">
|
||||
<img src="/coolify-logo.svg" alt="Coolify" class="w-6 h-6" />
|
||||
</a>
|
||||
<x-version class="text-[10px]" />
|
||||
</div>
|
||||
<div :class="collapsed && 'lg:hidden'">
|
||||
<!-- Search button that triggers global search modal -->
|
||||
<button @click="$dispatch('open-global-search')" type="button" title="Search (Press / or ⌘K)"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 bg-neutral-100 dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors">
|
||||
@@ -95,9 +120,11 @@
|
||||
class="px-1 py-0.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400 bg-neutral-200 dark:bg-coolgray-200 rounded">/</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<livewire:settings-dropdown />
|
||||
<div :class="collapsed && 'lg:hidden'">
|
||||
<livewire:settings-dropdown />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 pt-2 pb-7">
|
||||
<div class="px-2 pt-2 pb-7 overflow-hidden motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out motion-reduce:transition-none" :class="collapsed && 'lg:px-0 lg:pt-0 lg:pb-0 lg:min-h-[4.5rem] lg:flex lg:justify-center'">
|
||||
<livewire:switch-team />
|
||||
</div>
|
||||
<ul role="list" class="flex flex-col flex-1 gap-y-7">
|
||||
@@ -112,7 +139,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Dashboard</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -127,7 +154,7 @@
|
||||
<path d="M4 12l8 4l8 -4" />
|
||||
<path d="M4 16l8 4l8 -4" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Projects</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Projects</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -145,7 +172,7 @@
|
||||
<path d="M7 16v.01" />
|
||||
<path d="M20 15l-2 3h3l-2 3" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Servers</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Servers</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -157,7 +184,7 @@
|
||||
<path fill="currentColor"
|
||||
d="m6.793 1.207l.353.354l-.353-.354ZM1.207 6.793l-.353-.354l.353.354Zm0 1.414l.354-.353l-.354.353Zm5.586 5.586l-.354.353l.354-.353Zm1.414 0l-.353-.354l.353.354Zm5.586-5.586l.353.354l-.353-.354Zm0-1.414l-.354.353l.354-.353ZM8.207 1.207l.354-.353l-.354.353ZM6.44.854L.854 6.439l.707.707l5.585-5.585L6.44.854ZM.854 8.56l5.585 5.585l.707-.707l-5.585-5.585l-.707.707Zm7.707 5.585l5.585-5.585l-.707-.707l-5.585 5.585l.707.707Zm5.585-7.707L8.561.854l-.707.707l5.585 5.585l.707-.707Zm0 2.122a1.5 1.5 0 0 0 0-2.122l-.707.707a.5.5 0 0 1 0 .708l.707.707ZM6.44 14.146a1.5 1.5 0 0 0 2.122 0l-.707-.707a.5.5 0 0 1-.708 0l-.707.707ZM.854 6.44a1.5 1.5 0 0 0 0 2.122l.707-.707a.5.5 0 0 1 0-.708L.854 6.44Zm6.292-4.878a.5.5 0 0 1 .708 0L8.56.854a1.5 1.5 0 0 0-2.122 0l.707.707Zm-2 1.293l1 1l.708-.708l-1-1l-.708.708ZM7.5 5a.5.5 0 0 1-.5-.5H6A1.5 1.5 0 0 0 7.5 6V5Zm.5-.5a.5.5 0 0 1-.5.5v1A1.5 1.5 0 0 0 9 4.5H8ZM7.5 4a.5.5 0 0 1 .5.5h1A1.5 1.5 0 0 0 7.5 3v1Zm0-1A1.5 1.5 0 0 0 6 4.5h1a.5.5 0 0 1 .5-.5V3Zm.646 2.854l1.5 1.5l.707-.708l-1.5-1.5l-.707.708ZM10.5 8a.5.5 0 0 1-.5-.5H9A1.5 1.5 0 0 0 10.5 9V8Zm.5-.5a.5.5 0 0 1-.5.5v1A1.5 1.5 0 0 0 12 7.5h-1Zm-.5-.5a.5.5 0 0 1 .5.5h1A1.5 1.5 0 0 0 10.5 6v1Zm0-1A1.5 1.5 0 0 0 9 7.5h1a.5.5 0 0 1 .5-.5V6ZM7 5.5v4h1v-4H7Zm.5 5.5a.5.5 0 0 1-.5-.5H6A1.5 1.5 0 0 0 7.5 12v-1Zm.5-.5a.5.5 0 0 1-.5.5v1A1.5 1.5 0 0 0 9 10.5H8Zm-.5-.5a.5.5 0 0 1 .5.5h1A1.5 1.5 0 0 0 7.5 9v1Zm0-1A1.5 1.5 0 0 0 6 10.5h1a.5.5 0 0 1 .5-.5V9Z" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Sources</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Sources</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -170,7 +197,7 @@
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 4L3 8v12l6-3l6 3l6-4V4l-6 3l-6-3zm-2 8.001V12m4 .001V12m3-2l2 2m2 2l-2-2m0 0l2-2m-2 2l-2 2" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Destinations</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Destinations</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -185,7 +212,7 @@
|
||||
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="menu-item-label">S3 Storages</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">S3 Storages</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -200,7 +227,7 @@
|
||||
<path d="M8 16c1.5 0 3-2 4-3.5S14.5 9 16 9" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="menu-item-label">Shared Variables</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Shared Variables</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -212,7 +239,7 @@
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6M9 17v1a3 3 0 0 0 6 0v-1" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Notifications</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Notifications</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -224,7 +251,7 @@
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="m16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1-4.069 0l-.301-.301l-6.558 6.558a2 2 0 0 1-1.239.578L5.172 21H4a1 1 0 0 1-.993-.883L3 20v-1.172a2 2 0 0 1 .467-1.284l.119-.13L4 17h2v-2h2v-2l2.144-2.144l-.301-.301a2.877 2.877 0 0 1 0-4.069l2.643-2.643a2.877 2.877 0 0 1 4.069 0zM15 9h.01" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Keys & Tokens</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Keys & Tokens</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -239,7 +266,7 @@
|
||||
<path d="m18 19l1.592-1.592a4.82 4.82 0 0 0 0-6.816L15 6m-8 4h-.01" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="menu-item-label">Tags</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Tags</span>
|
||||
</a>
|
||||
</li>
|
||||
@can('canAccessTerminal')
|
||||
@@ -254,7 +281,7 @@
|
||||
<path d="M5 7l5 5l-5 5" />
|
||||
<path d="M12 19l7 0" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Terminal</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Terminal</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
@@ -270,7 +297,7 @@
|
||||
<path d="M12 10m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Profile</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -288,7 +315,7 @@
|
||||
<path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M3 13v-1a2 2 0 0 1 2 -2h2" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Teams</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Teams</span>
|
||||
</a>
|
||||
</li>
|
||||
@if (isCloud() && auth()->user()->isAdmin())
|
||||
@@ -301,7 +328,7 @@
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 2h18M7 15h.01M11 15h2" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Subscription</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Subscription</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@@ -319,7 +346,7 @@
|
||||
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" />
|
||||
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Settings</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@@ -333,7 +360,7 @@
|
||||
<path fill="currentColor"
|
||||
d="M177.62 159.6a52 52 0 0 1-34 34a12.2 12.2 0 0 1-3.6.55a12 12 0 0 1-3.6-23.45a28 28 0 0 0 18.32-18.32a12 12 0 0 1 22.9 7.2ZM220 144a92 92 0 0 1-184 0c0-28.81 11.27-58.18 33.48-87.28a12 12 0 0 1 17.9-1.33l19.69 19.11L127 19.89a12 12 0 0 1 18.94-5.12C168.2 33.25 220 82.85 220 144m-24 0c0-41.71-30.61-78.39-52.52-99.29l-20.21 55.4a12 12 0 0 1-19.63 4.5L80.71 82.36C67 103.38 60 124.06 60 144a68 68 0 0 0 136 0" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Admin</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Admin</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@@ -341,7 +368,7 @@
|
||||
<div class="flex-1"></div>
|
||||
@if (isInstanceAdmin() && !isCloud())
|
||||
@persist('upgrade')
|
||||
<li>
|
||||
<li :class="collapsed && 'lg:hidden'">
|
||||
<livewire:upgrade />
|
||||
</li>
|
||||
@endpersist
|
||||
@@ -368,7 +395,7 @@
|
||||
d="M12 6L8.707 9.293a1 1 0 0 0 0 1.414l.543.543c.69.69 1.81.69 2.5 0l1-1a3.182 3.182 0 0 1 4.5 0l2.25 2.25m-7 3l2 2M15 13l2 2" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="menu-item-label">Sponsor us</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Sponsor us</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
@@ -384,7 +411,7 @@
|
||||
<path fill="currentColor"
|
||||
d="M140 180a12 12 0 1 1-12-12a12 12 0 0 1 12 12M128 72c-22.06 0-40 16.15-40 36v4a8 8 0 0 0 16 0v-4c0-11 10.77-20 24-20s24 9 24 20s-10.77 20-24 20a8 8 0 0 0-8 8v8a8 8 0 0 0 16 0v-.72c18.24-3.35 32-17.9 32-35.28c0-19.85-17.94-36-40-36m104 56A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
|
||||
</svg>
|
||||
<span class="menu-item-label">Feedback</span>
|
||||
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Feedback</span>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:help />
|
||||
@@ -394,15 +421,21 @@
|
||||
<form action="/logout" method="POST">
|
||||
@csrf
|
||||
<button title="Logout" type="submit" class="gap-2 mb-6 menu-item">
|
||||
<svg class="menu-item-icon mr-1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class="menu-item-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2a9.985 9.985 0 0 1 8 4h-2.71a8 8 0 1 0 .001 12h2.71A9.985 9.985 0 0 1 12 22m7-6v-3h-8v-2h8V8l5 4z" />
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
<span :class="collapsed && 'lg:hidden'">Logout</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div x-show="collapsed && tooltip.show"
|
||||
x-cloak
|
||||
x-transition.opacity.duration.100ms
|
||||
:style="`left: ${tooltip.x}px; top: ${tooltip.y}px;`"
|
||||
class="fixed z-[100] -translate-y-1/2 px-2 py-1 text-xs font-medium rounded-md bg-neutral-900 dark:bg-coolgray-300 text-white whitespace-nowrap pointer-events-none shadow-lg border border-neutral-700 dark:border-coolgray-200"
|
||||
x-text="tooltip.text"></div>
|
||||
</nav>
|
||||
@@ -41,7 +41,7 @@
|
||||
@endif
|
||||
@if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel)
|
||||
<a class="sub-menu-item {{ $activeMenu === 'swarm' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.swarm', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Swarm (experimental)</span>
|
||||
href="{{ route('server.swarm', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Swarm</span>
|
||||
</a>
|
||||
@endif
|
||||
@if (!$server->isLocalhost())
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<x-emails.layout>
|
||||
Your Coolify API token ({{ $tokenName }}) expires on {{ $expiresAt }}.
|
||||
|
||||
Rotate this token before it expires. API calls using this token will start failing once the expiration time is reached.
|
||||
|
||||
Manage your API tokens [here]({{ $manageUrl }}).
|
||||
</x-emails.layout>
|
||||
@@ -7,18 +7,25 @@
|
||||
<!-- Global search component - included once to prevent keyboard shortcut duplication -->
|
||||
<livewire:global-search />
|
||||
@auth
|
||||
<livewire:deployments-indicator />
|
||||
<div x-data="{
|
||||
open: false,
|
||||
collapsed: false,
|
||||
pageWidth: 'full',
|
||||
init() {
|
||||
this.pageWidth = localStorage.getItem('pageWidth');
|
||||
if (!this.pageWidth) {
|
||||
this.pageWidth = 'full';
|
||||
localStorage.setItem('pageWidth', 'full');
|
||||
}
|
||||
this.collapsed = localStorage.getItem('sidebarCollapsed') === 'true';
|
||||
},
|
||||
toggleSidebar() {
|
||||
this.collapsed = !this.collapsed;
|
||||
localStorage.setItem('sidebarCollapsed', this.collapsed);
|
||||
}
|
||||
}" x-cloak class="mx-auto dark:text-inherit text-black"
|
||||
:class="pageWidth === 'full' ? '' : 'max-w-7xl'">
|
||||
<livewire:deployments-indicator />
|
||||
<div class="relative z-50 lg:hidden" :class="open ? 'block' : 'hidden'" role="dialog" aria-modal="true">
|
||||
<div class="fixed inset-0 bg-black/80" x-on:click="open = false"></div>
|
||||
<div class="fixed inset-y-0 right-0 h-full flex">
|
||||
@@ -40,10 +47,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0">
|
||||
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col min-w-0 transition-[width] duration-200"
|
||||
:class="collapsed ? 'lg:w-16' : 'lg:w-56'">
|
||||
<div class="flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0">
|
||||
<x-navbar />
|
||||
</div>
|
||||
<button type="button" @click="toggleSidebar()"
|
||||
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
class="absolute top-8 -right-3 z-50 hidden lg:flex items-center justify-center w-6 h-6 rounded-full border bg-white dark:bg-coolgray-100 dark:border-coolgray-200 border-neutral-300 hover:bg-neutral-100 dark:hover:bg-coolgray-200 transition-colors shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 text-neutral-600 dark:text-neutral-300 transition-transform"
|
||||
:class="collapsed ? '' : 'rotate-180'"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -62,7 +79,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main class="lg:pl-56">
|
||||
<main class="transition-[padding] duration-200" :class="collapsed ? 'lg:pl-16' : 'lg:pl-56'">
|
||||
<div class="p-4 sm:px-6 lg:px-8 lg:py-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<div wire:poll.3000ms x-data="{
|
||||
expanded: @entangle('expanded'),
|
||||
reduceOpacity: @js($this->shouldReduceOpacity)
|
||||
}" class="fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4">
|
||||
}" class="fixed bottom-0 left-0 z-60 mb-4 ml-4 transition-[left] duration-200"
|
||||
:class="collapsed ? 'lg:left-16' : 'lg:left-56'">
|
||||
@if ($this->deploymentCount > 0)
|
||||
<div class="relative transition-opacity duration-200"
|
||||
:class="{ 'opacity-100': expanded || !reduceOpacity, 'opacity-60 hover:opacity-100': reduceOpacity && !expanded }">
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
<a class="coolbox group" {{ wireNavigate() }}
|
||||
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
|
||||
<div class="flex flex-col mx-6">
|
||||
<div class="box-title">{{ $destination->name }}</div>
|
||||
<div class="box-title">
|
||||
{{ $destination->name }}
|
||||
<x-deprecated-badge />
|
||||
</div>
|
||||
<div class="box-description">server: {{ $destination->server->name }}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
||||
<div class="subtitle ">A simple Docker network.</div>
|
||||
@else
|
||||
<div class="subtitle ">A swarm Docker network. WIP</div>
|
||||
<div class="subtitle flex items-center gap-2">A swarm Docker network.
|
||||
<x-deprecated-badge />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$destination" id="name" label="Name" />
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
href="{{ route('project.application.advanced', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Advanced</span></a>
|
||||
@if ($application->destination->server->isSwarm())
|
||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||
href="{{ route('project.application.swarm', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Swarm Configuration</span></a>
|
||||
href="{{ route('project.application.swarm', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Swarm</span>
|
||||
</a>
|
||||
@endif
|
||||
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||
href="{{ route('project.application.environment-variables', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Environment Variables</span></a>
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
fullscreen: @entangle('fullscreen'),
|
||||
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
|
||||
rafId: null,
|
||||
scrollDebounce: null,
|
||||
isScrolling: false,
|
||||
lastTouchY: 0,
|
||||
showTimestamps: true,
|
||||
searchQuery: '',
|
||||
matchCount: 0,
|
||||
@@ -19,9 +22,54 @@
|
||||
scrollToBottom() {
|
||||
const logsContainer = document.getElementById('logsContainer');
|
||||
if (logsContainer) {
|
||||
this.isScrolling = true;
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
setTimeout(() => { this.isScrolling = false; }, 50);
|
||||
}
|
||||
},
|
||||
disableFollow() {
|
||||
if (!this.alwaysScroll) return;
|
||||
this.alwaysScroll = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
},
|
||||
handleWheel(event) {
|
||||
if (this.alwaysScroll && event.deltaY < 0) {
|
||||
this.disableFollow();
|
||||
}
|
||||
},
|
||||
handleTouchStart(event) {
|
||||
this.lastTouchY = event.touches[0].clientY;
|
||||
},
|
||||
handleTouchMove(event) {
|
||||
if (!this.alwaysScroll) return;
|
||||
const currentY = event.touches[0].clientY;
|
||||
if (currentY > this.lastTouchY) {
|
||||
this.disableFollow();
|
||||
}
|
||||
this.lastTouchY = currentY;
|
||||
},
|
||||
handleKeyScroll(event) {
|
||||
if (!this.alwaysScroll) return;
|
||||
const upKeys = ['ArrowUp', 'PageUp', 'Home'];
|
||||
if (upKeys.includes(event.key)) {
|
||||
this.disableFollow();
|
||||
}
|
||||
},
|
||||
handleScroll(event) {
|
||||
if (this.isScrolling) return;
|
||||
clearTimeout(this.scrollDebounce);
|
||||
this.scrollDebounce = setTimeout(() => {
|
||||
const el = event.target;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (!this.alwaysScroll && distanceFromBottom <= 10) {
|
||||
this.alwaysScroll = true;
|
||||
this.scheduleScroll();
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
scheduleScroll() {
|
||||
if (!this.alwaysScroll) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
@@ -107,9 +155,9 @@
|
||||
|
||||
this.matchCount = query ? count : 0;
|
||||
},
|
||||
downloadLogs() {
|
||||
collectVisibleLogs() {
|
||||
const logs = document.getElementById('logs');
|
||||
if (!logs) return;
|
||||
if (!logs) return '';
|
||||
const visibleLines = logs.querySelectorAll('[data-log-line]:not(.hidden)');
|
||||
let content = '';
|
||||
visibleLines.forEach(line => {
|
||||
@@ -118,6 +166,17 @@
|
||||
content += text + String.fromCharCode(10);
|
||||
}
|
||||
});
|
||||
return content;
|
||||
},
|
||||
copyLogs() {
|
||||
const content = this.collectVisibleLogs();
|
||||
if (!content) return;
|
||||
navigator.clipboard.writeText(content);
|
||||
Livewire.dispatch('success', ['Logs copied to clipboard.']);
|
||||
},
|
||||
downloadLogs() {
|
||||
const content = this.collectVisibleLogs();
|
||||
if (!content) return;
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -210,12 +269,7 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
x-on:click="
|
||||
$wire.copyLogs().then(logs => {
|
||||
navigator.clipboard.writeText(logs);
|
||||
Livewire.dispatch('success', ['Logs copied to clipboard.']);
|
||||
});
|
||||
"
|
||||
x-on:click="copyLogs()"
|
||||
title="Copy Logs"
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
@@ -331,7 +385,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logsContainer"
|
||||
<div id="logsContainer" @scroll="handleScroll" @wheel="handleWheel"
|
||||
@touchstart="handleTouchStart" @touchmove="handleTouchMove" @keydown="handleKeyScroll" tabindex="0"
|
||||
class="flex min-h-40 flex-1 flex-col overflow-y-auto p-2 px-4 scrollbar">
|
||||
<div id="logs" class="flex flex-col font-logs">
|
||||
<div x-show="searchQuery.trim() && matchCount === 0"
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<x-forms.select x-bind:disabled="shouldDisable()" wire:model.live="buildPack" label="Build Pack"
|
||||
required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
@@ -226,20 +227,24 @@
|
||||
id="customDockerRunOptions" label="Custom Docker Options" x-bind:disabled="!canUpdate" />
|
||||
@else
|
||||
@if ($application->could_set_build_commands())
|
||||
@if ($buildPack === 'nixpacks')
|
||||
@if ($buildPack === 'nixpacks' || $buildPack === 'railpack')
|
||||
<div class="flex flex-col gap-2 xl:flex-row">
|
||||
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
|
||||
<x-forms.input helper="If you modify this, you probably need to have a {{ $buildPack === 'railpack' ? 'railpack.json' : 'nixpacks.toml' }}"
|
||||
id="installCommand" label="Install Command" x-bind:disabled="!canUpdate" />
|
||||
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
|
||||
<x-forms.input helper="If you modify this, you probably need to have a {{ $buildPack === 'railpack' ? 'railpack.json' : 'nixpacks.toml' }}"
|
||||
id="buildCommand" label="Build Command" x-bind:disabled="!canUpdate" />
|
||||
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
|
||||
<x-forms.input helper="If you modify this, you probably need to have a {{ $buildPack === 'railpack' ? 'railpack.json' : 'nixpacks.toml' }}"
|
||||
id="startCommand" label="Start Command" x-bind:disabled="!canUpdate" />
|
||||
</div>
|
||||
<div class="pt-1 text-xs">Nixpacks will detect the required configuration
|
||||
automatically.
|
||||
@if ($buildPack === 'nixpacks')
|
||||
<div class="pt-1 text-xs">
|
||||
|
||||
<span class="font-medium">Nixpacks</span>
|
||||
will detect the required configuration automatically.
|
||||
<a class="underline" href="https://coolify.io/docs/applications/">Framework
|
||||
Specific Docs</a>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@endif
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<form wire:submit='submit' class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Swarm Configuration</h2>
|
||||
<x-deprecated-badge />
|
||||
@can('update', $application)
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
@@ -13,6 +14,9 @@
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
</div>
|
||||
<x-callout type="warning" title="Deprecated" class="my-4">
|
||||
{{ config('deprecations.swarm') }}
|
||||
</x-callout>
|
||||
<div class="flex flex-col gap-2 py-4">
|
||||
<div class="flex flex-col items-end gap-2 xl:flex-row">
|
||||
<x-forms.input id="swarmReplicas" label="Replicas" required canGate="update" :canResource="$application" />
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
min="0"
|
||||
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
|
||||
type="number" min="0"
|
||||
type="number" min="0" step="any"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." required />
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@
|
||||
min="0"
|
||||
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
|
||||
type="number" min="0"
|
||||
type="number" min="0" step="any"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col py-2 w-64">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<div class="flex items-center">
|
||||
@@ -92,11 +91,12 @@
|
||||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available" canGate="update"
|
||||
:canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
<div class="w-64">
|
||||
|
||||
@@ -129,14 +129,15 @@
|
||||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available" canGate="update"
|
||||
:canResource="$database" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
<div class="w-64">
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
<div class="w-64">
|
||||
<x-forms.checkbox helper="Drain logs to your configured log drain endpoint in your Server settings."
|
||||
instantSave="instantSaveAdvanced" id="isLogDrainEnabled" label="Drain Logs" canGate="update"
|
||||
:canResource="$database" />
|
||||
|
||||
@@ -129,14 +129,15 @@
|
||||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available" canGate="update"
|
||||
:canResource="$database" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
<x-forms.textarea
|
||||
helper="<a target='_blank' class='underline dark:text-white' href='https://raw.githubusercontent.com/Snapchat/KeyDB/unstable/keydb.conf'>KeyDB Default Configuration</a>"
|
||||
label="Custom KeyDB Configuration" rows="10" id="keydbConf" canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
<div class="w-64">
|
||||
|
||||
@@ -161,10 +161,12 @@
|
||||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
|
||||
canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.textarea label="Custom MariaDB Configuration" rows="10" id="mariadbConf"
|
||||
canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -167,10 +167,12 @@
|
||||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
|
||||
canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.textarea label="Custom MongoDB Configuration" rows="10" id="mongoConf"
|
||||
canGate="update" :canResource="$database" />
|
||||
|
||||
@@ -177,10 +177,12 @@
|
||||
</div>
|
||||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available" canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.textarea label="Custom Mysql Configuration" rows="10" id="mysqlConf" canGate="update" :canResource="$database" />
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
|
||||
@@ -179,10 +179,12 @@
|
||||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
|
||||
canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -148,10 +148,12 @@
|
||||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
|
||||
canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.textarea placeholder="# maxmemory 256mb
|
||||
# maxmemory-policy allkeys-lru
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<x-forms.input id="branch" required label="Branch" />
|
||||
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
@@ -60,6 +61,14 @@
|
||||
<x-forms.input id="publish_directory" required label="Publish Directory" />
|
||||
@endif
|
||||
</div>
|
||||
@if ($build_pack === 'railpack')
|
||||
<div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: '{{ $base_directory }}',
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
</x-forms.select>
|
||||
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
@@ -92,6 +93,14 @@
|
||||
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
|
||||
@endif
|
||||
</div>
|
||||
@if ($build_pack === 'railpack')
|
||||
<div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: '{{ $base_directory }}',
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
@endif
|
||||
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
@@ -51,6 +52,14 @@
|
||||
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
|
||||
@endif
|
||||
</div>
|
||||
@if ($build_pack === 'railpack')
|
||||
<div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: '{{ $base_directory }}',
|
||||
|
||||
@@ -452,6 +452,7 @@
|
||||
<div class="flex flex-col mx-6">
|
||||
<div class="font-bold dark:group-hover:text-white">
|
||||
Swarm Docker <span class="text-xs">({{ $swarmDocker->name }})</span>
|
||||
<x-deprecated-badge />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<div>
|
||||
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||
@if ($isReadOnly)
|
||||
@if ($fileStorage->is_too_large)
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
|
||||
</div>
|
||||
@elseif ($isReadOnly)
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
@if ($fileStorage->is_directory)
|
||||
This directory is mounted as read-only and cannot be modified from the UI.
|
||||
@@ -44,7 +48,7 @@
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" />
|
||||
@else
|
||||
@if (!$fileStorage->is_binary)
|
||||
@if (!$fileStorage->is_binary && !$fileStorage->is_too_large)
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
|
||||
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
|
||||
'The selected file will be permanently deleted and an empty directory will be created in its place.',
|
||||
@@ -76,8 +80,8 @@
|
||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
|
||||
rows="20" id="content"
|
||||
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
|
||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
|
||||
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"></x-forms.textarea>
|
||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large)
|
||||
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
|
||||
@endif
|
||||
@else
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@endif
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
@if (!$env->is_buildpack_control)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@@ -67,7 +67,7 @@
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$isMagicVariable)
|
||||
@if (!$env->is_nixpacks)
|
||||
@if (!$env->is_buildpack_control)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
@@ -244,7 +244,7 @@
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@endif
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
@if (!$env->is_buildpack_control)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@@ -253,7 +253,7 @@
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$isMagicVariable)
|
||||
@if (!$env->is_nixpacks)
|
||||
@if (!$env->is_buildpack_control)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
|
||||
@@ -28,6 +28,38 @@
|
||||
}
|
||||
},
|
||||
isScrolling: false,
|
||||
lastTouchY: 0,
|
||||
disableFollow() {
|
||||
if (!this.alwaysScroll) return;
|
||||
this.alwaysScroll = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
},
|
||||
handleWheel(event) {
|
||||
if (this.alwaysScroll && event.deltaY < 0) {
|
||||
this.disableFollow();
|
||||
}
|
||||
},
|
||||
handleTouchStart(event) {
|
||||
this.lastTouchY = event.touches[0].clientY;
|
||||
},
|
||||
handleTouchMove(event) {
|
||||
if (!this.alwaysScroll) return;
|
||||
const currentY = event.touches[0].clientY;
|
||||
if (currentY > this.lastTouchY) {
|
||||
this.disableFollow();
|
||||
}
|
||||
this.lastTouchY = currentY;
|
||||
},
|
||||
handleKeyScroll(event) {
|
||||
if (!this.alwaysScroll) return;
|
||||
const upKeys = ['ArrowUp', 'PageUp', 'Home'];
|
||||
if (upKeys.includes(event.key)) {
|
||||
this.disableFollow();
|
||||
}
|
||||
},
|
||||
scrollToBottom() {
|
||||
const logsContainer = document.getElementById('logsContainer');
|
||||
if (logsContainer) {
|
||||
@@ -57,17 +89,14 @@
|
||||
}
|
||||
},
|
||||
handleScroll(event) {
|
||||
if (!this.alwaysScroll || this.isScrolling) return;
|
||||
if (this.isScrolling) return;
|
||||
clearTimeout(this.scrollDebounce);
|
||||
this.scrollDebounce = setTimeout(() => {
|
||||
const el = event.target;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distanceFromBottom > 100) {
|
||||
this.alwaysScroll = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
if (!this.alwaysScroll && distanceFromBottom <= 10) {
|
||||
this.alwaysScroll = true;
|
||||
this.scheduleScroll();
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
@@ -473,7 +502,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logsContainer" @scroll="handleScroll"
|
||||
<div id="logsContainer" @scroll="handleScroll" @wheel="handleWheel"
|
||||
@touchstart="handleTouchStart" @touchmove="handleTouchMove" @keydown="handleKeyScroll" tabindex="0"
|
||||
class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0 scrollbar"
|
||||
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
||||
@if ($outputs)
|
||||
|
||||
@@ -14,8 +14,14 @@
|
||||
<h3>New Token</h3>
|
||||
@can('create', App\Models\PersonalAccessToken::class)
|
||||
<form class="flex flex-col gap-2" wire:submit='addNewToken'>
|
||||
<div class="flex gap-2 items-end w-96">
|
||||
<x-forms.input required id="description" label="Description" />
|
||||
<div class="flex gap-2 items-end w-lg">
|
||||
<x-forms.input class="w-64" required id="description" label="Description" />
|
||||
<x-forms.select id="expiresInDays" label="Expires in" wire:model="expiresInDays">
|
||||
@foreach ($expirationOptions as $days => $label)
|
||||
<option value="{{ $days }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
<option value="">Never</option>
|
||||
</x-forms.select>
|
||||
<x-forms.button type="submit">Create</x-forms.button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -59,7 +65,7 @@
|
||||
<x-forms.checkbox label="deploy (admin/owner only)" disabled domValue="deploy"
|
||||
helper="Deploy access requires admin or owner role" :checked="false"></x-forms.checkbox>
|
||||
@endif
|
||||
<x-forms.checkbox label="read" domValue="read" wire:model.live="permissions" domValue="read"
|
||||
<x-forms.checkbox label="read" wire:model.live="permissions" domValue="read"
|
||||
:checked="in_array('read', $permissions)"></x-forms.checkbox>
|
||||
@if ($canUseSensitivePermissions)
|
||||
<x-forms.checkbox label="read:sensitive" wire:model.live="permissions" domValue="read:sensitive"
|
||||
@@ -104,46 +110,83 @@
|
||||
<div class="flex items-center justify-between py-4">
|
||||
<h3>Issued Tokens</h3>
|
||||
@if ($tokens->count() > 1)
|
||||
<input type="text" x-model="search" placeholder="Filter tokens..."
|
||||
class="input w-64" />
|
||||
<input type="text" x-model="search" placeholder="Filter tokens..." class="input w-64" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
@forelse ($tokens as $token)
|
||||
<div wire:key="token-{{ $token->id }}"
|
||||
x-show="!search || '{{ strtolower($token->name) }} {{ strtolower(implode(' ', $token->abilities ?? [])) }}'.includes(search.toLowerCase())"
|
||||
class="flex items-center justify-between p-3 border rounded dark:border-coolgray-200">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold dark:text-white">{{ $token->name }}</span>
|
||||
@if ($token->abilities)
|
||||
@foreach ($token->abilities as $ability)
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded-sm font-medium {{ $ability === 'root' ? 'bg-red-500/20 text-red-400' : ($ability === 'write' || $ability === 'write:sensitive' ? 'bg-amber-500/20 text-amber-400' : ($ability === 'deploy' ? 'bg-blue-500/20 text-blue-400' : 'bg-neutral-500/20 text-neutral-300')) }}">
|
||||
{{ $ability }}
|
||||
</span>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-xs text-neutral-400">
|
||||
Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="inline-block min-w-full">
|
||||
<div class="overflow-hidden">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Description</th>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Permissions</th>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Last used</th>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Created</th>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Expires</th>
|
||||
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($tokens as $token)
|
||||
<tr wire:key="token-{{ $token->id }}"
|
||||
x-show="!search || {{ Js::from(strtolower($token->name).' '.strtolower(implode(' ', $token->abilities ?? []))) }}.includes(search.toLowerCase())">
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap">{{ $token->name }}</td>
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||
@if ($token->abilities)
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
@foreach ($token->abilities as $ability)
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded-sm font-medium {{ $ability === 'root' ? 'bg-red-500/20 text-red-400' : ($ability === 'write' || $ability === 'write:sensitive' ? 'bg-amber-500/20 text-amber-400' : ($ability === 'deploy' ? 'bg-blue-500/20 text-blue-400' : 'bg-neutral-500/20 text-neutral-300')) }}">
|
||||
{{ $ability }}
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||
{{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||
{{ $token->created_at->diffForHumans() }}
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap">
|
||||
@if (! $token->expires_at)
|
||||
Never
|
||||
@elseif ($token->expires_at->isPast())
|
||||
<span class="font-bold dark:text-error">Expired
|
||||
{{ $token->expires_at->format('Y-m-d H:i:s') }}</span>
|
||||
@else
|
||||
{{ $token->expires_at->format('Y-m-d H:i:s') }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-5 py-4 text-sm font-medium whitespace-nowrap">
|
||||
@if (auth()->id() === $token->tokenable_id)
|
||||
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton
|
||||
buttonTitle="Revoke token"
|
||||
submitAction="revoke({{ data_get($token, 'id') }})" :actions="[
|
||||
'This API Token will be revoked and permanently deleted.',
|
||||
'Any API call made with this token will fail.',
|
||||
]"
|
||||
confirmationText="{{ $token->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the API Token Description below"
|
||||
shortConfirmationLabel="API Token Description" :confirmWithPassword="false"
|
||||
step2ButtonText="Revoke API Token" />
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td class="px-5 py-4 text-sm whitespace-nowrap" colspan="6">No API tokens found.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (auth()->id() === $token->tokenable_id)
|
||||
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton
|
||||
buttonTitle="Revoke" submitAction="revoke({{ data_get($token, 'id') }})"
|
||||
:actions="[
|
||||
'This API Token will be revoked and permanently deleted.',
|
||||
'Any API call made with this token will fail.',
|
||||
]" confirmationText="{{ $token->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the API Token Description below"
|
||||
shortConfirmationLabel="API Token Description" :confirmWithPassword="false"
|
||||
step2ButtonText="Revoke API Token" />
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-neutral-400">No API tokens found.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -191,6 +191,12 @@
|
||||
label="Port" required :disabled="$isValidating" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-64">
|
||||
<x-forms.input canGate="update" :canResource="$server" type="number"
|
||||
id="connectionTimeout" label="SSH Connection Timeout (s)"
|
||||
helper="Seconds to wait for SSH connection before failing. Default: 10."
|
||||
min="1" max="300" required :disabled="$isValidating" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center mb-1">
|
||||
<label for="serverTimezone">Server Timezone</label>
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
<div class="w-full">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Swarm <span class="text-xs text-neutral-500">(experimental)</span></h2>
|
||||
<h2>Swarm</h2>
|
||||
<x-deprecated-badge />
|
||||
</div>
|
||||
<x-callout type="warning" title="Deprecated" class="my-4">
|
||||
{{ config('deprecations.swarm') }}
|
||||
</x-callout>
|
||||
<div class="pb-4">Read the docs <a class='underline dark:text-white'
|
||||
href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>.
|
||||
</div>
|
||||
|
||||
@@ -70,6 +70,17 @@
|
||||
environments!
|
||||
</x-callout>
|
||||
@endif
|
||||
<h4 class="pt-4">MCP Server</h4>
|
||||
<div class="md:w-96">
|
||||
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server"
|
||||
helper="Exposes a Streamable HTTP Model Context Protocol endpoint at /mcp for AI clients (Claude Desktop, Cursor, etc.). Authenticates via Sanctum API tokens (Security > API Tokens). Requires API Access to be enabled." />
|
||||
</div>
|
||||
@if ($is_mcp_server_enabled)
|
||||
<x-callout type="info" title="MCP Endpoint" class="mt-2">
|
||||
Endpoint: <code>{{ url('/mcp') }}</code><br>
|
||||
Authenticate with <code>Authorization: Bearer <token></code> using a token created in Security » API Tokens.
|
||||
</x-callout>
|
||||
@endif
|
||||
<h4 class="pt-4">UI Settings</h4>
|
||||
<div class="md:w-96">
|
||||
<x-forms.checkbox instantSave id="is_wire_navigate_enabled" label="SPA Navigation"
|
||||
|
||||
@@ -249,23 +249,23 @@
|
||||
{{-- Refund --}}
|
||||
<section>
|
||||
<h3 class="pb-2">Refund</h3>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($refundCheckLoading)
|
||||
<x-forms.button disabled>Request Full Refund</x-forms.button>
|
||||
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
|
||||
isErrorButton submitAction="refundSubscription"
|
||||
:actions="[
|
||||
'Your latest payment will be fully refunded.',
|
||||
'Your subscription will be cancelled immediately.',
|
||||
'All servers will be deactivated.',
|
||||
]" confirmationText="{{ currentTeam()->name }}"
|
||||
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
|
||||
step2ButtonText="Confirm Refund & Cancel" />
|
||||
@else
|
||||
<x-forms.button disabled>Request Full Refund</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
@if ($refundCheckLoading || ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end))
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@if ($refundCheckLoading)
|
||||
<x-forms.button disabled>Request Full Refund</x-forms.button>
|
||||
@else
|
||||
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
|
||||
isErrorButton submitAction="refundSubscription"
|
||||
:actions="[
|
||||
'Your latest payment will be fully refunded.',
|
||||
'Your subscription will be cancelled immediately.',
|
||||
'All servers will be deactivated.',
|
||||
]" confirmationText="{{ currentTeam()->name }}"
|
||||
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
|
||||
step2ButtonText="Confirm Refund & Cancel" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<p class="mt-2 text-sm text-neutral-500">
|
||||
@if ($refundCheckLoading)
|
||||
Checking refund eligibility...
|
||||
|
||||
@@ -1,6 +1,49 @@
|
||||
<x-forms.select wire:model.live="selectedTeamId">
|
||||
<option value="default" disabled selected>Switch team</option>
|
||||
@foreach (auth()->user()->teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
@php
|
||||
$currentTeam = auth()->user()->currentTeam();
|
||||
$teamInitial = strtoupper(mb_substr($currentTeam->name, 0, 1));
|
||||
@endphp
|
||||
<div>
|
||||
<div :class="collapsed && 'lg:hidden'">
|
||||
<x-forms.select wire:model.live="selectedTeamId">
|
||||
<option value="default" disabled selected>Switch team</option>
|
||||
@foreach (auth()->user()->teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="hidden"
|
||||
:class="collapsed && 'lg:block'"
|
||||
x-data="{
|
||||
teamOpen: false,
|
||||
teamX: 0,
|
||||
teamY: 0,
|
||||
openTeamMenu(ev) {
|
||||
const rect = ev.currentTarget.getBoundingClientRect();
|
||||
this.teamX = rect.right + 8;
|
||||
this.teamY = rect.top;
|
||||
this.teamOpen = !this.teamOpen;
|
||||
}
|
||||
}">
|
||||
<button @click="openTeamMenu($event)" type="button"
|
||||
title="Team: {{ $currentTeam->name }}"
|
||||
class="flex items-center justify-center w-8 h-8 text-sm font-semibold rounded-md bg-coollabs hover:opacity-80 transition-opacity text-white cursor-pointer">
|
||||
{{ $teamInitial }}
|
||||
</button>
|
||||
<div x-show="teamOpen"
|
||||
@click.outside="teamOpen = false"
|
||||
x-transition.opacity.duration.100ms
|
||||
x-cloak
|
||||
:style="`left: ${teamX}px; top: ${teamY}px;`"
|
||||
class="fixed z-[100] min-w-48 max-h-72 overflow-y-auto bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md shadow-lg py-1">
|
||||
<div class="px-3 py-1.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-coolgray-200">Switch team</div>
|
||||
@foreach (auth()->user()->teams as $team)
|
||||
<button type="button"
|
||||
wire:click="switch_to({{ $team->id }})"
|
||||
@click="teamOpen = false"
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 dark:text-white {{ $team->id === $currentTeam->id ? 'font-semibold text-coollabs dark:text-warning' : '' }}">
|
||||
{{ $team->name }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in new issue
Block a user