Merge remote-tracking branch 'origin/next' into s3-backup-validation
This commit is contained in:
commit
8e30035031
16 changed files with 236 additions and 65 deletions
|
|
@ -69,7 +69,7 @@ public static function generateScpCommand(Server $server, string $source, string
|
|||
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
||||
}
|
||||
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||
{
|
||||
if ($server->settings->force_disabled) {
|
||||
throw new \RuntimeException('Server is disabled.');
|
||||
|
|
@ -80,7 +80,8 @@ public static function generateSshCommand(Server $server, string $command, bool
|
|||
|
||||
self::validateSshKey($server->privateKey);
|
||||
|
||||
$sshCommand = 'timeout '.config('constants.ssh.command_timeout').' ssh ';
|
||||
$commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
|
||||
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
|
||||
|
||||
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
||||
$sshCommand .= self::multiplexingOptions($server);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ class Terminal extends Component
|
|||
{
|
||||
public bool $hasShell = true;
|
||||
|
||||
public bool $isTerminalConnected = false;
|
||||
|
||||
private function checkShellAvailability(Server $server, string $container): bool
|
||||
{
|
||||
$escapedContainer = escapeshellarg($container);
|
||||
|
|
@ -65,12 +67,20 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
|
|||
$dockerCommand = "sudo {$dockerCommand}";
|
||||
}
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
|
||||
$command = SshMultiplexingHelper::generateSshCommand(
|
||||
$server,
|
||||
$dockerCommand,
|
||||
commandTimeout: (int) config('constants.terminal.command_timeout')
|
||||
);
|
||||
} else {
|
||||
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
|
||||
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
|
||||
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand);
|
||||
$command = SshMultiplexingHelper::generateSshCommand(
|
||||
$server,
|
||||
$shellCommand,
|
||||
commandTimeout: (int) config('constants.terminal.command_timeout')
|
||||
);
|
||||
}
|
||||
// ssh command is sent back to frontend then to websocket
|
||||
// this is done because the websocket connection is not available here
|
||||
|
|
@ -84,6 +94,23 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
|
|||
$this->dispatch('send-back-command', $command);
|
||||
}
|
||||
|
||||
#[On('terminalConnected')]
|
||||
public function markTerminalConnected(): void
|
||||
{
|
||||
$this->isTerminalConnected = true;
|
||||
}
|
||||
|
||||
#[On('terminalDisconnected')]
|
||||
public function markTerminalDisconnected(): void
|
||||
{
|
||||
$this->isTerminalConnected = false;
|
||||
}
|
||||
|
||||
public function keepTerminalPageAlive(): void
|
||||
{
|
||||
$this->isTerminalConnected = true;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.terminal');
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
'protocol' => env('TERMINAL_PROTOCOL'),
|
||||
'host' => env('TERMINAL_HOST'),
|
||||
'port' => env('TERMINAL_PORT'),
|
||||
'command_timeout' => 0,
|
||||
],
|
||||
|
||||
'pusher' => [
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
|
||||
pull_policy: always
|
||||
container_name: coolify-realtime
|
||||
restart: always
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
extractSshArgs,
|
||||
extractTargetHost,
|
||||
extractTimeout,
|
||||
getTerminalSessionTimeout,
|
||||
isAuthorizedTargetHost,
|
||||
} from './terminal-utils.js';
|
||||
|
||||
|
|
@ -63,9 +64,11 @@ function createHttpError(response) {
|
|||
}
|
||||
|
||||
const userSessions = new Map();
|
||||
const terminalDebugEnabled = ['1', 'true', 'yes'].includes(
|
||||
String(process.env.TERMINAL_DEBUG || '').toLowerCase()
|
||||
);
|
||||
const envName = String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase();
|
||||
const debugOverride = String(process.env.TERMINAL_DEBUG || '').toLowerCase();
|
||||
const terminalDebugEnabled =
|
||||
['local', 'development'].includes(envName)
|
||||
|| ['1', 'true', 'yes', 'on'].includes(debugOverride);
|
||||
|
||||
function logTerminal(level, message, context = {}) {
|
||||
if (!terminalDebugEnabled) {
|
||||
|
|
@ -154,7 +157,6 @@ const verifyClient = async (info, callback) => {
|
|||
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
wss.on('connection', async (ws, req) => {
|
||||
ws.isAlive = true;
|
||||
|
|
@ -168,9 +170,9 @@ wss.on('connection', async (ws, req) => {
|
|||
ptyProcess: null,
|
||||
isActive: false,
|
||||
authorizedIPs: [],
|
||||
lastActivityAt: Date.now(),
|
||||
authReady: false,
|
||||
pendingMessages: [],
|
||||
terminalSessionTimer: null,
|
||||
};
|
||||
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
|
||||
const connectionContext = {
|
||||
|
|
@ -260,29 +262,6 @@ const heartbeat = setInterval(() => {
|
|||
} catch (_) {
|
||||
// ignore — close handler will follow
|
||||
}
|
||||
|
||||
const session = ws.userId ? userSessions.get(ws.userId) : null;
|
||||
if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
|
||||
const idleMs = Date.now() - session.lastActivityAt;
|
||||
logTerminal('warn', 'Closing terminal session due to idle timeout.', {
|
||||
userId: ws.userId,
|
||||
idleMs,
|
||||
idleTimeoutMs: IDLE_TIMEOUT_MS,
|
||||
});
|
||||
try {
|
||||
ws.send('idle-timeout');
|
||||
} catch (_) {
|
||||
// ignore — close still attempted below
|
||||
}
|
||||
killPtyProcess(ws.userId);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ws.close(1000, 'Idle timeout');
|
||||
} catch (_) {
|
||||
// ignore — already closed
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
|
|
@ -290,11 +269,9 @@ wss.on('close', () => clearInterval(heartbeat));
|
|||
|
||||
const messageHandlers = {
|
||||
message: (session, data) => {
|
||||
session.lastActivityAt = Date.now();
|
||||
session.ptyProcess.write(data);
|
||||
},
|
||||
resize: (session, { cols, rows }) => {
|
||||
session.lastActivityAt = Date.now();
|
||||
cols = cols > 0 ? cols : 80;
|
||||
rows = rows > 0 ? rows : 30;
|
||||
session.ptyProcess.resize(cols, rows)
|
||||
|
|
@ -365,8 +342,14 @@ async function handleCommand(ws, command, userId) {
|
|||
}
|
||||
}
|
||||
|
||||
if (userSession.terminalSessionTimer) {
|
||||
clearTimeout(userSession.terminalSessionTimer);
|
||||
userSession.terminalSessionTimer = null;
|
||||
}
|
||||
|
||||
const commandString = command[0].split('\n').join(' ');
|
||||
const timeout = extractTimeout(commandString);
|
||||
const commandTimeout = extractTimeout(commandString);
|
||||
const terminalSessionTimeout = getTerminalSessionTimeout();
|
||||
const sshArgs = extractSshArgs(commandString);
|
||||
const hereDocContent = extractHereDocContent(commandString);
|
||||
|
||||
|
|
@ -375,7 +358,8 @@ async function handleCommand(ws, command, userId) {
|
|||
logTerminal('log', 'Parsed terminal command metadata.', {
|
||||
userId,
|
||||
targetHost,
|
||||
timeout,
|
||||
commandTimeout,
|
||||
terminalSessionTimeout,
|
||||
sshArgs,
|
||||
authorizedIPs: userSession?.authorizedIPs ?? [],
|
||||
});
|
||||
|
|
@ -414,13 +398,13 @@ async function handleCommand(ws, command, userId) {
|
|||
logTerminal('log', 'Spawning PTY process for terminal session.', {
|
||||
userId,
|
||||
targetHost,
|
||||
timeout,
|
||||
commandTimeout,
|
||||
terminalSessionTimeout,
|
||||
});
|
||||
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
|
||||
|
||||
userSession.ptyProcess = ptyProcess;
|
||||
userSession.isActive = true;
|
||||
userSession.lastActivityAt = Date.now();
|
||||
|
||||
ws.send('pty-ready');
|
||||
|
||||
|
|
@ -437,13 +421,16 @@ async function handleCommand(ws, command, userId) {
|
|||
});
|
||||
ws.send('pty-exited');
|
||||
userSession.isActive = false;
|
||||
|
||||
if (userSession.terminalSessionTimer) {
|
||||
clearTimeout(userSession.terminalSessionTimer);
|
||||
userSession.terminalSessionTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (timeout) {
|
||||
setTimeout(async () => {
|
||||
await killPtyProcess(userId);
|
||||
}, timeout * 1000);
|
||||
}
|
||||
userSession.terminalSessionTimer = setTimeout(async () => {
|
||||
await killPtyProcess(userId);
|
||||
}, terminalSessionTimeout * 1000);
|
||||
}
|
||||
|
||||
async function handleError(err, userId) {
|
||||
|
|
@ -485,6 +472,11 @@ async function killPtyProcess(userId) {
|
|||
|
||||
setTimeout(() => {
|
||||
if (!session.isActive || !session.ptyProcess) {
|
||||
if (session.terminalSessionTimer) {
|
||||
clearTimeout(session.terminalSessionTimer);
|
||||
session.terminalSessionTimer = null;
|
||||
}
|
||||
|
||||
logTerminal('log', 'PTY process terminated successfully.', {
|
||||
userId,
|
||||
killAttempts,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
|
||||
|
||||
export function getTerminalSessionTimeout() {
|
||||
return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS;
|
||||
}
|
||||
|
||||
export function extractTimeout(commandString) {
|
||||
const timeoutMatch = commandString.match(/timeout (\d+)/);
|
||||
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
|
||||
extractSshArgs,
|
||||
extractTargetHost,
|
||||
getTerminalSessionTimeout,
|
||||
isAuthorizedTargetHost,
|
||||
normalizeHostForAuthorization,
|
||||
} from './terminal-utils.js';
|
||||
|
|
@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
|
|||
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
|
||||
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
|
||||
});
|
||||
|
||||
|
||||
test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => {
|
||||
assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
|
||||
assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
|
||||
assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
|
||||
pull_policy: always
|
||||
container_name: coolify-realtime
|
||||
restart: always
|
||||
|
|
|
|||
22
resources/js/terminal-session-timer.js
Normal file
22
resources/js/terminal-session-timer.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export const MAX_TERMINAL_SESSION_SECONDS = 8 * 60 * 60;
|
||||
export const TERMINAL_SESSION_WARNING_SECONDS = 30 * 60;
|
||||
export const TERMINAL_SESSION_DANGER_SECONDS = 5 * 60;
|
||||
|
||||
export function formatTerminalSessionRemainingTime(seconds) {
|
||||
const remainingSeconds = Math.max(0, Math.ceil(seconds));
|
||||
|
||||
if (remainingSeconds === 0) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
const totalMinutes = Math.floor(remainingSeconds / 60);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const secondsPart = remainingSeconds % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}m ${String(secondsPart).padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secondsPart).padStart(2, '0')}s`;
|
||||
}
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
import { Terminal } from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import {
|
||||
MAX_TERMINAL_SESSION_SECONDS,
|
||||
TERMINAL_SESSION_DANGER_SECONDS,
|
||||
TERMINAL_SESSION_WARNING_SECONDS,
|
||||
formatTerminalSessionRemainingTime,
|
||||
} from './terminal-session-timer.js';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
const terminalDebugEnabled = import.meta.env.DEV;
|
||||
|
|
@ -44,7 +50,7 @@ export function initializeTerminalComponent() {
|
|||
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).
|
||||
// (pty-exited, unprocessable).
|
||||
lastSentCommand: null,
|
||||
// Resize handling
|
||||
resizeObserver: null,
|
||||
|
|
@ -52,6 +58,9 @@ export function initializeTerminalComponent() {
|
|||
// Visibility handling - prevent disconnects when tab loses focus
|
||||
isDocumentVisible: true,
|
||||
wasConnectedBeforeHidden: false,
|
||||
terminalSessionStartedAt: null,
|
||||
terminalSessionRemainingSeconds: null,
|
||||
terminalSessionCountdownInterval: null,
|
||||
|
||||
init() {
|
||||
this.setupTerminal();
|
||||
|
|
@ -135,6 +144,7 @@ export function initializeTerminalComponent() {
|
|||
this.clearAllTimers();
|
||||
this.connectionState = 'disconnected';
|
||||
this.pendingCommand = null;
|
||||
this.resetTerminalSessionCountdown();
|
||||
if (this.socket) {
|
||||
this.socket.close(1000, 'Client cleanup');
|
||||
}
|
||||
|
|
@ -157,11 +167,68 @@ export function initializeTerminalComponent() {
|
|||
}
|
||||
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
|
||||
.forEach(timer => timer && clearTimeout(timer));
|
||||
if (this.terminalSessionCountdownInterval) {
|
||||
clearInterval(this.terminalSessionCountdownInterval);
|
||||
}
|
||||
this.keepAliveInterval = null;
|
||||
this.reconnectInterval = null;
|
||||
this.connectionTimeoutId = null;
|
||||
this.pingTimeoutId = null;
|
||||
this.resizeTimeout = null;
|
||||
this.terminalSessionCountdownInterval = null;
|
||||
},
|
||||
|
||||
resetTerminalSessionCountdown() {
|
||||
if (this.terminalSessionCountdownInterval) {
|
||||
clearInterval(this.terminalSessionCountdownInterval);
|
||||
}
|
||||
|
||||
this.terminalSessionStartedAt = null;
|
||||
this.terminalSessionRemainingSeconds = null;
|
||||
this.terminalSessionCountdownInterval = null;
|
||||
},
|
||||
|
||||
startTerminalSessionCountdown() {
|
||||
this.resetTerminalSessionCountdown();
|
||||
this.terminalSessionStartedAt = Date.now();
|
||||
this.updateTerminalSessionCountdown();
|
||||
this.terminalSessionCountdownInterval = setInterval(() => {
|
||||
this.updateTerminalSessionCountdown();
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
updateTerminalSessionCountdown() {
|
||||
if (!this.terminalSessionStartedAt) {
|
||||
this.terminalSessionRemainingSeconds = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = (Date.now() - this.terminalSessionStartedAt) / 1000;
|
||||
this.terminalSessionRemainingSeconds = Math.max(0, MAX_TERMINAL_SESSION_SECONDS - elapsedSeconds);
|
||||
},
|
||||
|
||||
terminalSessionRemainingLabel() {
|
||||
if (this.terminalSessionRemainingSeconds === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `Session expires in ${formatTerminalSessionRemainingTime(this.terminalSessionRemainingSeconds)}`;
|
||||
},
|
||||
|
||||
terminalSessionTimerClass() {
|
||||
if (this.terminalSessionRemainingSeconds === null) {
|
||||
return 'text-neutral-300 bg-black/70 border-white/10';
|
||||
}
|
||||
|
||||
if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_DANGER_SECONDS) {
|
||||
return 'text-red-200 bg-red-950/80 border-red-500/40';
|
||||
}
|
||||
|
||||
if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_WARNING_SECONDS) {
|
||||
return 'text-yellow-200 bg-yellow-950/80 border-yellow-500/40';
|
||||
}
|
||||
|
||||
return 'text-neutral-300 bg-black/70 border-white/10';
|
||||
},
|
||||
|
||||
resetTerminal() {
|
||||
|
|
@ -181,6 +248,7 @@ export function initializeTerminalComponent() {
|
|||
this.paused = false;
|
||||
this.commandBuffer = '';
|
||||
this.pendingCommand = null;
|
||||
this.resetTerminalSessionCountdown();
|
||||
|
||||
// Notify parent component that terminal disconnected
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
|
|
@ -328,6 +396,7 @@ export function initializeTerminalComponent() {
|
|||
|
||||
this.connectionState = 'disconnected';
|
||||
this.clearAllTimers();
|
||||
this.resetTerminalSessionCountdown();
|
||||
|
||||
// Only reset terminal and reconnect if it wasn't a clean close
|
||||
if (event.code !== 1000) {
|
||||
|
|
@ -424,6 +493,7 @@ export function initializeTerminalComponent() {
|
|||
}
|
||||
}
|
||||
this.terminalActive = true;
|
||||
this.startTerminalSessionCountdown();
|
||||
this.term.focus();
|
||||
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm');
|
||||
|
||||
|
|
@ -450,27 +520,20 @@ export function initializeTerminalComponent() {
|
|||
if (this.term) this.term.reset();
|
||||
this.terminalActive = false;
|
||||
this.lastSentCommand = null;
|
||||
this.resetTerminalSessionCountdown();
|
||||
this.message = '(sorry, something went wrong, please try again)';
|
||||
|
||||
// Notify parent component that terminal connection failed
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (event.data === 'pty-exited') {
|
||||
this.terminalActive = false;
|
||||
this.resetTerminalSessionCountdown();
|
||||
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:'))
|
||||
|
|
@ -478,6 +541,7 @@ export function initializeTerminalComponent() {
|
|||
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
|
||||
this.$wire.dispatch('error', event.data);
|
||||
this.terminalActive = false;
|
||||
this.resetTerminalSessionCountdown();
|
||||
} else {
|
||||
try {
|
||||
this.pendingWrites++;
|
||||
|
|
|
|||
15
resources/js/terminal.test.js
Normal file
15
resources/js/terminal.test.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
MAX_TERMINAL_SESSION_SECONDS,
|
||||
formatTerminalSessionRemainingTime,
|
||||
} from './terminal-session-timer.js';
|
||||
|
||||
test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => {
|
||||
assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60);
|
||||
assert.equal(formatTerminalSessionRemainingTime(MAX_TERMINAL_SESSION_SECONDS), '8h 00m 00s');
|
||||
assert.equal(formatTerminalSessionRemainingTime((7 * 60 * 60) + (59 * 60) + 59), '7h 59m 59s');
|
||||
assert.equal(formatTerminalSessionRemainingTime(65 * 60), '1h 05m 00s');
|
||||
assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s');
|
||||
assert.equal(formatTerminalSessionRemainingTime(0), 'expired');
|
||||
});
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
<div id="terminal-container" x-data="terminalData()">
|
||||
@if ($isTerminalConnected)
|
||||
<div class="hidden" aria-hidden="true" wire:poll.keep-alive.30s="keepTerminalPageAlive"></div>
|
||||
@endif
|
||||
@if (!$hasShell)
|
||||
<div class="flex pt-4 items-center justify-center w-full py-4 mx-auto">
|
||||
<div class="p-4 w-full rounded-sm border dark:bg-coolgray-100 dark:border-coolgray-300">
|
||||
|
|
@ -20,6 +23,11 @@
|
|||
<div x-ref="terminalWrapper"
|
||||
:class="fullscreen ? 'fullscreen !bg-black' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
|
||||
<!-- Terminal container -->
|
||||
<div x-show="terminalActive" x-cloak class="mb-2 flex justify-start">
|
||||
<div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium"
|
||||
:class="terminalSessionTimerClass()" x-text="terminalSessionRemainingLabel()">
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal" wire:ignore
|
||||
:class="fullscreen ? 'px-2 py-1 h-full bg-black' : 'px-2 py-1 rounded-sm bg-black'" x-show="terminalActive">
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,22 +58,35 @@
|
|||
->toContain("'Visibility-resume timeout'");
|
||||
});
|
||||
|
||||
it('closes idle terminal sessions after 30 minutes on the server', function () {
|
||||
it('does not hard close terminal sessions after 30 minutes on the server', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
|
||||
->toContain('lastActivityAt')
|
||||
->toContain("ws.send('idle-timeout');")
|
||||
->toContain("ws.close(1000, 'Idle timeout');");
|
||||
->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
|
||||
->not->toContain("ws.send('idle-timeout');")
|
||||
->not->toContain("ws.close(1000, 'Idle timeout');");
|
||||
});
|
||||
|
||||
it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () {
|
||||
it('does not close the client terminal from an idle-timeout sentinel', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("event.data === 'idle-timeout'")
|
||||
->toContain('Terminal closed after 30 minutes of inactivity.');
|
||||
->not->toContain("event.data === 'idle-timeout'")
|
||||
->not->toContain('Terminal closed after 30 minutes of inactivity.');
|
||||
});
|
||||
|
||||
it('keeps Livewire alive in background tabs while a terminal is connected', function () {
|
||||
$terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
|
||||
$terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalComponent)
|
||||
->toContain('public bool $isTerminalConnected = false;')
|
||||
->toContain("#[On('terminalConnected')]")
|
||||
->toContain('public function markTerminalConnected(): void')
|
||||
->toContain('public function keepTerminalPageAlive(): void')
|
||||
->and($terminalView)
|
||||
->toContain('@if ($isTerminalConnected)')
|
||||
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
|
||||
});
|
||||
|
||||
it('replays the last command on reconnect so the PTY respawns automatically', function () {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,19 @@ function makeMuxServer(): Server
|
|||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('can generate terminal ssh commands without a hard command timeout', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
|
||||
|
||||
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
|
||||
|
||||
expect($command)
|
||||
->toStartWith('ssh ')
|
||||
->not->toStartWith('timeout ')
|
||||
->not->toContain('timeout 3600 ssh');
|
||||
});
|
||||
|
||||
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
|
||||
config(['constants.ssh.mux_enabled' => true]);
|
||||
$server = makeMuxServer();
|
||||
|
|
|
|||
Loading…
Reference in a new issue