fixes for terminal

This commit is contained in:
Andras Bacsai
2024-09-11 12:19:27 +02:00
parent 33e9c9b0f9
commit 117fbeb07c
12 changed files with 279 additions and 226 deletions

View File

@@ -11,17 +11,19 @@ class Terminal extends Component
#[On('send-terminal-command')] #[On('send-terminal-command')]
public function sendTerminalCommand($isContainer, $identifier, $serverUuid) public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
{ {
$server = Server::whereUuid($serverUuid)->firstOrFail(); $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail();
if (auth()->user()) { // if (auth()->user()) {
$teams = auth()->user()->teams->pluck('id'); // $teams = auth()->user()->teams->pluck('id');
if (! $teams->contains($server->team_id) && ! $teams->contains(0)) { // if (! $teams->contains($server->team_id) && ! $teams->contains(0)) {
throw new \Exception('User is not part of the team that owns this server'); // throw new \Exception('User is not part of the team that owns this server');
} // }
} // }
if ($isContainer) { if ($isContainer) {
ray($identifier);
$status = getContainerStatus($server, $identifier); $status = getContainerStatus($server, $identifier);
ray($status);
if ($status !== 'running') { if ($status !== 'running') {
return handleError(new \Exception('Container is not running'), $this); return handleError(new \Exception('Container is not running'), $this);
} }

View File

@@ -2,7 +2,6 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\Server;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
@@ -23,7 +22,7 @@ class RunCommand extends Component
private function getAllActiveContainers() private function getAllActiveContainers()
{ {
return Server::all()->flatMap(function ($server) { return collect($this->servers)->flatMap(function ($server) {
if (! $server->isFunctional()) { if (! $server->isFunctional()) {
return []; return [];
} }
@@ -31,25 +30,52 @@ class RunCommand extends Component
return $server->definedResources() return $server->definedResources()
->filter(function ($resource) { ->filter(function ($resource) {
$status = method_exists($resource, 'realStatus') ? $resource->realStatus() : (method_exists($resource, 'status') ? $resource->status() : 'exited'); $status = method_exists($resource, 'realStatus') ? $resource->realStatus() : (method_exists($resource, 'status') ? $resource->status() : 'exited');
return str_starts_with($status, 'running:'); return str_starts_with($status, 'running:');
}) })
->map(function ($resource) use ($server) { ->map(function ($resource) use ($server) {
$container_name = $resource->uuid; if (isDev()) {
if (data_get($resource, 'name') === 'coolify-db') {
$container_name = 'coolify-db';
if (class_basename($resource) === 'Application' || class_basename($resource) === 'Service') { return [
if ($server->isSwarm()) { 'name' => $resource->name,
$container_name = $resource->uuid.'_'.$resource->uuid; 'connection_name' => $container_name,
} else { 'uuid' => $resource->uuid,
$current_containers = getCurrentApplicationContainerStatus($server, $resource->id, includePullrequests: true); 'status' => 'running',
$container_name = data_get($current_containers->first(), 'Names'); 'server' => $server,
'server_uuid' => $server->uuid,
];
} }
} }
if (class_basename($resource) === 'Application') {
if (! $server->isSwarm()) {
$current_containers = getCurrentApplicationContainerStatus($server, $resource->id, includePullrequests: true);
}
$status = $resource->status;
} elseif (class_basename($resource) === 'Service') {
$current_containers = getCurrentServiceContainerStatus($server, $resource->id);
$status = $resource->status();
} else {
$status = getContainerStatus($server, $resource->uuid);
if ($status === 'running') {
$current_containers = collect([
'Names' => $resource->name,
]);
}
}
if ($server->isSwarm()) {
$container_name = $resource->uuid.'_'.$resource->uuid;
} else {
$container_name = data_get($current_containers->first(), 'Names');
}
return [ return [
'name' => $resource->name, 'name' => $resource->name,
'connection_name' => $container_name, 'connection_name' => $container_name,
'uuid' => $resource->uuid, 'uuid' => $resource->uuid,
'status' => $resource->status, 'status' => $status,
'server' => $server, 'server' => $server,
'server_uuid' => $server->uuid, 'server_uuid' => $server->uuid,
]; ];

View File

@@ -40,6 +40,20 @@ function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pul
return $containers; return $containers;
} }
function getCurrentServiceContainerStatus(Server $server, int $id): Collection
{
$containers = collect([]);
if (! $server->isSwarm()) {
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --format '{{json .}}' "], $server);
$containers = format_docker_command_output_to_json($containers);
$containers = $containers->filter();
return $containers;
}
return $containers;
}
function format_docker_command_output_to_json($rawOutput): Collection function format_docker_command_output_to_json($rawOutput): Collection
{ {
$outputLines = explode(PHP_EOL, $rawOutput); $outputLines = explode(PHP_EOL, $rawOutput);

View File

@@ -43,25 +43,23 @@ services:
- /data/coolify/_volumes/redis/:/data - /data/coolify/_volumes/redis/:/data
# - coolify-redis-data-dev:/data # - coolify-redis-data-dev:/data
soketi: soketi:
build:
context: .
dockerfile: ./docker/coolify-realtime/Dockerfile
env_file: env_file:
- .env - .env
ports: ports:
- "${FORWARD_SOKETI_PORT:-6001}:6001" - "${FORWARD_SOKETI_PORT:-6001}:6001"
- "6002:6002" - "6002:6002"
volumes: volumes:
- ./docker/soketi-entrypoint/soketi-entrypoint.sh:/soketi-entrypoint.sh
- ./package.json:/terminal/package.json
- ./package-lock.json:/terminal/package-lock.json
- ./terminal-server.js:/terminal/terminal-server.js
- ./storage:/var/www/html/storage - ./storage:/var/www/html/storage
entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"]
environment: environment:
SOKETI_DEBUG: "false" SOKETI_DEBUG: "false"
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
vite: vite:
image: node:alpine image: node:20
pull_policy: always pull_policy: always
working_dir: /var/www/html working_dir: /var/www/html
# environment: # environment:

View File

@@ -109,6 +109,7 @@ services:
retries: 10 retries: 10
timeout: 2s timeout: 2s
soketi: soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:latest'
ports: ports:
- "${SOKETI_PORT:-6001}:6001" - "${SOKETI_PORT:-6001}:6001"
- "6002:6002" - "6002:6002"

View File

@@ -102,8 +102,8 @@ services:
interval: 5s interval: 5s
retries: 10 retries: 10
timeout: 2s timeout: 2s
soketi: soketi:
image: 'quay.io/soketi/soketi:1.6-16-alpine' image: 'ghcr.io/coollabsio/coolify-realtime:latest'
pull_policy: always pull_policy: always
container_name: coolify-realtime container_name: coolify-realtime
restart: always restart: always

View File

@@ -24,7 +24,6 @@ services:
networks: networks:
- coolify - coolify
soketi: soketi:
image: 'quay.io/soketi/soketi:1.6-16-alpine'
container_name: coolify-realtime container_name: coolify-realtime
restart: always restart: always
networks: networks:

View File

@@ -0,0 +1,9 @@
FROM quay.io/soketi/soketi:1.6-16-alpine
WORKDIR /terminal
RUN apk add --no-cache openssh-client make g++ python3
COPY docker/coolify-realtime/package.json ./
RUN npm i
RUN npm rebuild node-pty --update-binary
COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh
COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js
ENTRYPOINT ["/bin/sh", "/soketi-entrypoint.sh"]

View File

@@ -0,0 +1,13 @@
{
"private": true,
"type": "module",
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"cookie": "^0.6.0",
"axios": "1.7.5",
"dotenv": "^16.4.5",
"node-pty": "^1.0.0",
"ws": "^8.17.0"
}
}

View File

@@ -1,16 +1,4 @@
#!/bin/sh #!/bin/sh
# Install openssh-client
apk add --no-cache openssh-client make g++ python3
cd /terminal
# Install npm dependencies
npm ci
# Rebuild node-pty
npm rebuild node-pty --update-binary
# Function to timestamp logs # Function to timestamp logs
timestamp() { timestamp() {
date "+%Y-%m-%d %H:%M:%S" date "+%Y-%m-%d %H:%M:%S"

View File

@@ -16,40 +16,40 @@ const server = http.createServer((req, res) => {
}); });
const verifyClient = async (info, callback) => { const verifyClient = async (info, callback) => {
const cookies = cookie.parse(info.req.headers.cookie || ''); const cookies = cookie.parse(info.req.headers.cookie || '');
const origin = new URL(info.origin); const origin = new URL(info.origin);
const protocol = origin.protocol; const protocol = origin.protocol;
const xsrfToken = cookies['XSRF-TOKEN']; const xsrfToken = cookies['XSRF-TOKEN'];
// Generate session cookie name based on APP_NAME // Generate session cookie name based on APP_NAME
const appName = process.env.APP_NAME || 'laravel'; const appName = process.env.APP_NAME || 'laravel';
const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`; const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`;
const laravelSession = cookies[sessionCookieName]; const laravelSession = cookies[sessionCookieName];
// Verify presence of required tokens // Verify presence of required tokens
if (!laravelSession || !xsrfToken) { if (!laravelSession || !xsrfToken) {
return callback(false, 401, 'Unauthorized: Missing required tokens'); return callback(false, 401, 'Unauthorized: Missing required tokens');
} }
try { try {
// Authenticate with Laravel backend // Authenticate with Laravel backend
const response = await axios.post(`${protocol}//coolify/terminal/auth`, null, { const response = await axios.post(`${protocol}//coolify/terminal/auth`, null, {
headers: { headers: {
'Cookie': `${sessionCookieName}=${laravelSession}`, 'Cookie': `${sessionCookieName}=${laravelSession}`,
'X-XSRF-TOKEN': xsrfToken 'X-XSRF-TOKEN': xsrfToken
}, },
}); });
if (response.status === 200) { if (response.status === 200) {
// Authentication successful // Authentication successful
callback(true); callback(true);
} else { } else {
callback(false, 401, 'Unauthorized: Invalid credentials'); callback(false, 401, 'Unauthorized: Invalid credentials');
}
} catch (error) {
console.error('Authentication error:', error.message);
callback(false, 500, 'Internal Server Error');
} }
} catch (error) {
console.error('Authentication error:', error.message);
callback(false, 500, 'Internal Server Error');
}
}; };

View File

@@ -1,11 +1,12 @@
<div x-data="data()"> <div x-data="data()">
<div x-show="!terminalActive" class="flex items-center justify-center w-full py-4 mx-auto h-[510px]"> <div x-show="!terminalActive" class="flex items-center justify-center w-full py-4 mx-auto h-[510px]">
<div class="w-full h-full border rounded dark:bg-coolgray-100 dark:border-coolgray-300"> <div
<span class="font-mono text-sm text-gray-500">(connection closed)</span> class="w-full h-full bg-white border border-solid rounded dark:text-white dark:bg-coolgray-100 scrollbar border-neutral-300 dark:border-coolgray-300 p-1">
<span class="font-mono text-sm text-gray-500 ">(connection closed)</span>
</div> </div>
</div> </div>
<div x-ref="terminalWrapper" <div x-ref="terminalWrapper"
:class="fullscreen ? 'fullscreen' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'"> :class="fullscreen ? 'fullscreen' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
<div id="terminal" wire:ignore></div> <div id="terminal" wire:ignore></div>
<button title="Minimize" x-show="fullscreen" class="fixed top-4 right-4" x-on:click="makeFullscreen"><svg <button title="Minimize" x-show="fullscreen" class="fixed top-4 right-4" x-on:click="makeFullscreen"><svg
class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@@ -24,169 +25,171 @@
</div> </div>
@script @script
<script> <script>
const MAX_PENDING_WRITES = 5; const MAX_PENDING_WRITES = 5;
let pendingWrites = 0; let pendingWrites = 0;
let paused = false; let paused = false;
let socket; let socket;
let commandBuffer = ''; let commandBuffer = '';
function initializeWebSocket() { function initializeWebSocket() {
if (!socket || socket.readyState === WebSocket.CLOSED) { if (!socket || socket.readyState === WebSocket.CLOSED) {
const url = "{{ str_replace(['http://', 'https://'], '', config('app.url')) }}" || window.location.hostname; let url = "{{ str_replace(['http://', 'https://'], '', config('app.url')) }}" || window.location.hostname;
socket = new WebSocket((window.location.protocol === 'https:' ? 'wss://' : 'ws://') + // make sure the port is not included
url + url = url.split(':')[0];
':6002/terminal'); socket = new WebSocket((window.location.protocol === 'https:' ? 'wss://' : 'ws://') +
socket.onmessage = handleSocketMessage; url +
socket.onerror = (e) => { ':6002/terminal');
console.error('WebSocket error:', e); socket.onmessage = handleSocketMessage;
}; socket.onerror = (e) => {
} console.error('WebSocket error:', e);
} };
function handleSocketMessage(event) {
// Initialize Terminal
if (event.data === 'pty-ready') {
term.open(document.getElementById('terminal'));
$data.terminalActive = true;
term.reset();
term.focus();
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded')
$data.resizeTerminal()
} else {
pendingWrites++;
term.write(event.data, flowControlCallback);
}
}
function flowControlCallback() {
pendingWrites--;
if (pendingWrites > MAX_PENDING_WRITES && !paused) {
paused = true;
socket.send(JSON.stringify({
pause: true
}));
return;
}
if (pendingWrites <= MAX_PENDING_WRITES && paused) {
paused = false;
socket.send(JSON.stringify({
resume: true
}));
return;
}
}
term.onData((data) => {
socket.send(JSON.stringify({
message: data
}));
// Type CTRL + D or exit in the terminal
if (data === '\x04' || (data === '\r' && stripAnsiCommands(commandBuffer).trim() === 'exit')) {
checkIfProcessIsRunningAndKillIt();
setTimeout(() => {
term.reset();
term.write('(connection closed)');
$data.terminalActive = false;
}, 500);
commandBuffer = '';
} else if (data === '\r') {
commandBuffer = '';
} else {
commandBuffer += data;
}
});
function stripAnsiCommands(input) {
return input.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
}
// Copy and paste
// Enables ctrl + c and ctrl + v
// defaults otherwise to ctrl + insert, shift + insert
term.attachCustomKeyEventHandler((arg) => {
if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") {
navigator.clipboard.readText()
.then(text => {
socket.send(JSON.stringify({
message: text
}));
})
};
if (arg.ctrlKey && arg.code === "KeyC" && arg.type === "keydown") {
const selection = term.getSelection();
if (selection) {
navigator.clipboard.writeText(selection);
return false;
} }
} }
return true;
});
$wire.on('send-back-command', function(command) { function handleSocketMessage(event) {
socket.send(JSON.stringify({ // Initialize Terminal
command: command if (event.data === 'pty-ready') {
})); term.open(document.getElementById('terminal'));
}); $data.terminalActive = true;
term.reset();
term.focus();
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded')
$data.resizeTerminal()
} else {
pendingWrites++;
term.write(event.data, flowControlCallback);
}
}
window.addEventListener('beforeunload', function(e) { function flowControlCallback() {
checkIfProcessIsRunningAndKillIt(); pendingWrites--;
}); if (pendingWrites > MAX_PENDING_WRITES && !paused) {
paused = true;
socket.send(JSON.stringify({
pause: true
}));
return;
}
if (pendingWrites <= MAX_PENDING_WRITES && paused) {
paused = false;
socket.send(JSON.stringify({
resume: true
}));
return;
}
}
function checkIfProcessIsRunningAndKillIt() { term.onData((data) => {
socket.send(JSON.stringify({
checkActive: 'force'
}));
}
window.onresize = function() {
$data.resizeTerminal()
};
Alpine.data('data', () => ({
fullscreen: false,
terminalActive: false,
init() {
this.$watch('terminalActive', (value) => {
this.$nextTick(() => {
if (value) {
$refs.terminalWrapper.style.display = 'block';
this.resizeTerminal();
} else {
$refs.terminalWrapper.style.display = 'none';
}
});
});
},
makeFullscreen() {
this.fullscreen = !this.fullscreen;
$nextTick(() => {
this.resizeTerminal()
})
},
resizeTerminal() {
if (!this.terminalActive) return;
fitAddon.fit();
const height = $refs.terminalWrapper.clientHeight;
const rows = height / term._core._renderService._charSizeService.height - 1;
var termWidth = term.cols;
var termHeight = parseInt(rows.toString(), 10);
term.resize(termWidth, termHeight);
socket.send(JSON.stringify({ socket.send(JSON.stringify({
resize: { message: data
cols: termWidth, }));
rows: termHeight
// Type CTRL + D or exit in the terminal
if (data === '\x04' || (data === '\r' && stripAnsiCommands(commandBuffer).trim() === 'exit')) {
checkIfProcessIsRunningAndKillIt();
setTimeout(() => {
term.reset();
term.write('(connection closed)');
$data.terminalActive = false;
}, 500);
commandBuffer = '';
} else if (data === '\r') {
commandBuffer = '';
} else {
commandBuffer += data;
}
});
function stripAnsiCommands(input) {
return input.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
}
// Copy and paste
// Enables ctrl + c and ctrl + v
// defaults otherwise to ctrl + insert, shift + insert
term.attachCustomKeyEventHandler((arg) => {
if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") {
navigator.clipboard.readText()
.then(text => {
socket.send(JSON.stringify({
message: text
}));
})
};
if (arg.ctrlKey && arg.code === "KeyC" && arg.type === "keydown") {
const selection = term.getSelection();
if (selection) {
navigator.clipboard.writeText(selection);
return false;
} }
}
return true;
});
$wire.on('send-back-command', function(command) {
socket.send(JSON.stringify({
command: command
}));
});
window.addEventListener('beforeunload', function(e) {
checkIfProcessIsRunningAndKillIt();
});
function checkIfProcessIsRunningAndKillIt() {
socket.send(JSON.stringify({
checkActive: 'force'
})); }));
} }
}));
initializeWebSocket(); window.onresize = function() {
</script> $data.resizeTerminal()
};
Alpine.data('data', () => ({
fullscreen: false,
terminalActive: false,
init() {
this.$watch('terminalActive', (value) => {
this.$nextTick(() => {
if (value) {
$refs.terminalWrapper.style.display = 'block';
this.resizeTerminal();
} else {
$refs.terminalWrapper.style.display = 'none';
}
});
});
},
makeFullscreen() {
this.fullscreen = !this.fullscreen;
$nextTick(() => {
this.resizeTerminal()
})
},
resizeTerminal() {
if (!this.terminalActive) return;
fitAddon.fit();
const height = $refs.terminalWrapper.clientHeight;
const rows = height / term._core._renderService._charSizeService.height - 1;
var termWidth = term.cols;
var termHeight = parseInt(rows.toString(), 10);
term.resize(termWidth, termHeight);
socket.send(JSON.stringify({
resize: {
cols: termWidth,
rows: termHeight
}
}));
}
}));
initializeWebSocket();
</script>
@endscript @endscript
</div> </div>