Merge remote-tracking branch 'origin/next' into feat/railpack

This commit is contained in:
Andras Bacsai committed 2026-04-28 14:36:54 +02:00
commit 5cef7cc092
153 files changed
+5603 -773

No files matched your search

+71 -18
View File
@@ -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>
+3 -2
View File
@@ -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>
@@ -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>
@@ -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(() => {
@@ -327,7 +375,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"
@@ -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>
@@ -54,7 +54,6 @@
readonly value="Starting the database will generate this." canGate="update" :canResource="$database" />
@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">
@@ -76,11 +75,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">
@@ -113,14 +113,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" />
@@ -113,14 +113,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">
@@ -137,10 +137,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" />
@@ -151,10 +151,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" />
@@ -153,10 +153,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>
@@ -163,10 +163,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">
@@ -132,10 +132,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
@@ -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>
@@ -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,13 +14,19 @@
<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">
Permissions
<x-helper class="px-1" helper="These permissions will be granted to the token." /><span
<span
class="pr-1">:</span>
<div class="flex gap-1 font-bold dark:text-white">
@if ($permissions)
@@ -31,7 +37,6 @@
</div>
</div>
<h4>Token Permissions</h4>
<div class="w-64">
@if ($canUseRootPermissions)
<x-forms.checkbox label="root" wire:model.live="permissions" domValue="root"
@@ -71,38 +76,78 @@
<div class="pb-4 font-bold dark:text-white"> {{ session('token') }}</div>
@endif
<h3 class="py-4">Issued Tokens</h3>
<div class="grid gap-2 lg:grid-cols-1">
@forelse ($tokens as $token)
<div wire:key="token-{{ $token->id }}"
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
<div>Description: {{ $token->name }}</div>
<div>Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}</div>
<div class="flex gap-1">
@if ($token->abilities)
Permissions:
@foreach ($token->abilities as $ability)
<div class="font-bold dark:text-white">{{ $ability }}</div>
@endforeach
@endif
<div class="flex flex-col">
<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 }}">
<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">
@foreach ($token->abilities as $ability)
<div class="font-bold dark:text-white">{{ $ability }}</div>
@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>
</div>
@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
</div>
@empty
<div>
<div>No API tokens found.</div>
</div>
@endforelse
</div>
</div>
@endif
</div>
@@ -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>