Commit the control-panel terminal frontend (xterm) so clean builds keep it
Deploy Ladill Hosting / deploy (push) Successful in 1m21s
Deploy Ladill Hosting / deploy (push) Successful in 1m21s
The terminal Alpine component + xterm/xterm-addon-fit only ever existed in an out-of-band build; committed source never had them, so clean CI rebuilds shipped a bundle WITHOUT the terminal (blank panel). Add resources/js/hosting-terminal.js (window.hostingInteractiveTerminal, recovered from the last working build), import it from app.js, and add xterm deps to package.json/lock. Verified: a clean 'npm ci && vite build' now includes the component + xterm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ff3959b734
commit
e86f95ddc7
Generated
+21
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ladill-email",
|
||||
"name": "ladill-hosting",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -7,7 +7,9 @@
|
||||
"dependencies": {
|
||||
"@alpinejs/collapse": "^3.15.12",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"alpinejs": "^3.15.12"
|
||||
"alpinejs": "^3.15.12",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -2489,6 +2491,23 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xterm-addon-fit": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
|
||||
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
+3
-1
@@ -17,6 +17,8 @@
|
||||
"dependencies": {
|
||||
"@alpinejs/collapse": "^3.15.12",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"alpinejs": "^3.15.12"
|
||||
"alpinejs": "^3.15.12",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import './bootstrap';
|
||||
|
||||
// Registers window.hostingInteractiveTerminal (xterm.js) used by the control-panel
|
||||
// terminal blade. Must stay imported so clean builds include the terminal.
|
||||
import './hosting-terminal';
|
||||
|
||||
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
||||
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
||||
import { registerLadillDomainPurchase } from './ladill-domain-purchase';
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
// Browser terminal (xterm.js) Alpine factory for the hosting control panel.
|
||||
// Exposed as a global so the panel blade can use x-data="hostingInteractiveTerminal({...})".
|
||||
// NOTE: this frontend previously only existed in an out-of-band build and was
|
||||
// lost on clean CI rebuilds — keep it committed.
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
|
||||
window.hostingInteractiveTerminal = (config = {}) => {
|
||||
console.log('[Terminal] hostingInteractiveTerminal called with config:', config);
|
||||
return {
|
||||
sessionId: null,
|
||||
offset: 0,
|
||||
term: null,
|
||||
fitAddon: null,
|
||||
pollTimer: null,
|
||||
resizeTimer: null,
|
||||
inputTimer: null,
|
||||
pendingInput: '',
|
||||
commandLine: '',
|
||||
overlayMessage: 'Connecting to shell...',
|
||||
canReconnect: false,
|
||||
statusLabel: 'Connecting',
|
||||
terminalActive: false,
|
||||
termHasOutput: false,
|
||||
currentPath: config.initialPath ?? '/public_html',
|
||||
currentPathLabel: `Starting in ${config.initialPath === '/' ? `/home/${config.username ?? ''}` : `/home/${config.username ?? ''}${config.initialPath ?? '/public_html'}`}`,
|
||||
promptPath() {
|
||||
if (this.currentPath === '/' || !this.currentPath) {
|
||||
return '~';
|
||||
}
|
||||
|
||||
return `~${this.currentPath}`;
|
||||
},
|
||||
writePrompt(command = '') {
|
||||
const prompt = `\x1b[36m${config.username ?? 'user'}@ladill:${this.promptPath()}$ \x1b[0m`;
|
||||
const suffix = command ? `${command}\r\n` : '';
|
||||
this.term?.write(`${prompt}${suffix}`);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
convertEol: true,
|
||||
allowTransparency: true,
|
||||
disableStdin: false,
|
||||
fontFamily: '"JetBrains Mono", "SF Mono", "Fira Code", "Monaco", "Consolas", monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: 0,
|
||||
theme: {
|
||||
background: '#0a0a0a',
|
||||
foreground: '#e4e4e7',
|
||||
cursor: '#a1a1aa',
|
||||
cursorAccent: '#0a0a0a',
|
||||
selectionBackground: 'rgba(161, 161, 170, 0.2)',
|
||||
selectionForeground: '#fafafa',
|
||||
black: '#18181b',
|
||||
red: '#f87171',
|
||||
green: '#4ade80',
|
||||
yellow: '#fbbf24',
|
||||
blue: '#60a5fa',
|
||||
magenta: '#c084fc',
|
||||
cyan: '#22d3ee',
|
||||
white: '#e4e4e7',
|
||||
brightBlack: '#52525b',
|
||||
brightRed: '#fca5a5',
|
||||
brightGreen: '#86efac',
|
||||
brightYellow: '#fde047',
|
||||
brightBlue: '#93c5fd',
|
||||
brightMagenta: '#d8b4fe',
|
||||
brightCyan: '#67e8f9',
|
||||
brightWhite: '#fafafa',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Terminal] Initializing terminal...');
|
||||
this.fitAddon = new FitAddon();
|
||||
this.term.loadAddon(this.fitAddon);
|
||||
this.term.open(this.$refs.terminal);
|
||||
this.term.onData((data) => {
|
||||
if (this.sessionId) {
|
||||
this.queueInput(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Delay initial fit to ensure container has dimensions
|
||||
requestAnimationFrame(() => {
|
||||
this.fitAddon.fit();
|
||||
this.term.focus();
|
||||
});
|
||||
|
||||
// Use ResizeObserver for more reliable resize detection
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(() => this.queueResize());
|
||||
this.resizeObserver.observe(this.$refs.terminal);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => this.queueResize());
|
||||
window.addEventListener('beforeunload', () => this.shutdown(true));
|
||||
|
||||
this.start();
|
||||
},
|
||||
|
||||
async start() {
|
||||
this.statusLabel = 'Connecting';
|
||||
this.overlayMessage = 'Connecting to shell...';
|
||||
this.canReconnect = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(config.startUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: config.initialPath ?? '/public_html',
|
||||
cols: this.term.cols,
|
||||
rows: this.term.rows,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok || !payload.session_id) {
|
||||
throw new Error(payload.message || 'Unable to start the browser terminal session.');
|
||||
}
|
||||
|
||||
if (payload.status === 'failed') {
|
||||
throw new Error(payload.error || 'Unable to start the browser terminal session.');
|
||||
}
|
||||
|
||||
this.sessionId = payload.session_id;
|
||||
this.offset = 0;
|
||||
this.statusLabel = payload.status === 'starting' ? 'Connecting' : 'Live';
|
||||
this.overlayMessage = payload.status === 'starting' ? 'Connecting to shell...' : '';
|
||||
this.canReconnect = false;
|
||||
this.queueResize();
|
||||
this.poll();
|
||||
// Delay focus to ensure terminal is fully rendered
|
||||
setTimeout(() => this.focusTerminal(), 100);
|
||||
} catch (error) {
|
||||
this.statusLabel = 'Unavailable';
|
||||
this.overlayMessage = error.message || 'Unable to connect to the browser terminal.';
|
||||
this.canReconnect = true;
|
||||
}
|
||||
},
|
||||
|
||||
buildUrl(template) {
|
||||
return String(template || '').replace('__SESSION__', this.sessionId ?? '');
|
||||
},
|
||||
|
||||
bindTerminalInputCapture() {
|
||||
console.log('[Terminal] bindTerminalInputCapture called');
|
||||
const terminalElement = this.$refs.terminal;
|
||||
|
||||
if (!terminalElement || terminalElement.dataset.keyboardCaptureBound === 'true') {
|
||||
console.log('[Terminal] Already bound or no element');
|
||||
return;
|
||||
}
|
||||
|
||||
this.boundTerminalKeydownHandler = (event) => this.handleTerminalKeydown(event);
|
||||
this.boundTerminalPasteHandler = (event) => this.handleTerminalPaste(event);
|
||||
this.boundTerminalPointerHandler = (event) => {
|
||||
this.terminalActive = this.$refs.terminal?.contains(event.target) ?? false;
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', this.boundTerminalKeydownHandler, true);
|
||||
window.addEventListener('paste', this.boundTerminalPasteHandler, true);
|
||||
window.addEventListener('pointerdown', this.boundTerminalPointerHandler, true);
|
||||
terminalElement.dataset.keyboardCaptureBound = 'true';
|
||||
console.log('[Terminal] Input capture bound successfully');
|
||||
},
|
||||
|
||||
queueInput(data) {
|
||||
if (!this.sessionId || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingInput += data;
|
||||
|
||||
if (this.inputTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inputTimer = window.setTimeout(() => this.flushInput(), 16);
|
||||
},
|
||||
|
||||
async flushInput() {
|
||||
const payload = this.pendingInput;
|
||||
this.pendingInput = '';
|
||||
this.inputTimer = null;
|
||||
|
||||
if (!this.sessionId || payload === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Terminal] Sending input:', JSON.stringify(payload));
|
||||
|
||||
try {
|
||||
const response = await fetch(this.buildUrl(config.inputUrlTemplate), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain;charset=UTF-8',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
console.log('[Terminal] Input response:', response.status);
|
||||
} catch (e) {
|
||||
console.error('[Terminal] Input error:', e);
|
||||
this.term?.write('\r\n[Input delivery failed]\r\n');
|
||||
}
|
||||
},
|
||||
|
||||
handleTerminalKeydown(event) {
|
||||
console.log('[Terminal] keydown:', event.key, 'terminalActive:', this.terminalActive, 'sessionId:', !!this.sessionId);
|
||||
|
||||
if (!this.shouldCaptureTerminalInput(event)) {
|
||||
console.log('[Terminal] Not capturing input');
|
||||
return;
|
||||
}
|
||||
|
||||
const sequence = this.keyEventSequence(event);
|
||||
console.log('[Terminal] sequence:', sequence ? JSON.stringify(sequence) : 'null');
|
||||
|
||||
if (sequence === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation?.();
|
||||
this.queueInput(sequence);
|
||||
},
|
||||
|
||||
handleTerminalPaste(event) {
|
||||
if (!this.shouldCaptureTerminalInput(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.clipboardData?.getData('text/plain') ?? '';
|
||||
|
||||
if (text === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation?.();
|
||||
this.queueInput(text.replace(/\r?\n/g, '\r'));
|
||||
},
|
||||
|
||||
shouldCaptureTerminalInput(event) {
|
||||
if (!this.sessionId || !this.terminalActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
const terminalElement = this.$refs.terminal;
|
||||
|
||||
if (terminalElement && target instanceof Node && terminalElement.contains(target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !target.closest('input, textarea, select, button, [contenteditable="true"], a[href]');
|
||||
},
|
||||
|
||||
keyEventSequence(event) {
|
||||
if (!this.sessionId || event.metaKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.ctrlKey && !event.altKey) {
|
||||
const key = String(event.key || '').toLowerCase();
|
||||
|
||||
if (key >= 'a' && key <= 'z') {
|
||||
return String.fromCharCode(key.charCodeAt(0) - 96);
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case ' ':
|
||||
case '@':
|
||||
return '\x00';
|
||||
case '[':
|
||||
return '\x1b';
|
||||
case '\\':
|
||||
return '\x1c';
|
||||
case ']':
|
||||
return '\x1d';
|
||||
case '^':
|
||||
return '\x1e';
|
||||
case '_':
|
||||
return '\x1f';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.altKey && !event.ctrlKey && String(event.key || '').length === 1) {
|
||||
return `\x1b${event.key}`;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
return '\r';
|
||||
case 'Backspace':
|
||||
return '\x7f';
|
||||
case 'Tab':
|
||||
return event.shiftKey ? '\x1b[Z' : '\t';
|
||||
case 'Escape':
|
||||
return '\x1b';
|
||||
case 'ArrowUp':
|
||||
return '\x1b[A';
|
||||
case 'ArrowDown':
|
||||
return '\x1b[B';
|
||||
case 'ArrowRight':
|
||||
return '\x1b[C';
|
||||
case 'ArrowLeft':
|
||||
return '\x1b[D';
|
||||
case 'Home':
|
||||
return '\x1b[H';
|
||||
case 'End':
|
||||
return '\x1b[F';
|
||||
case 'Insert':
|
||||
return '\x1b[2~';
|
||||
case 'Delete':
|
||||
return '\x1b[3~';
|
||||
case 'PageUp':
|
||||
return '\x1b[5~';
|
||||
case 'PageDown':
|
||||
return '\x1b[6~';
|
||||
default:
|
||||
return event.ctrlKey || event.altKey || String(event.key || '').length !== 1
|
||||
? null
|
||||
: event.key;
|
||||
}
|
||||
},
|
||||
|
||||
poll() {
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(this.buildUrl(config.readUrlTemplate), window.location.origin);
|
||||
url.searchParams.set('offset', String(this.offset));
|
||||
|
||||
fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
console.log('[Terminal] Poll response:', response.status, 'output length:', payload.output?.length ?? 0, 'status:', payload.status);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || 'Unable to read terminal output.');
|
||||
}
|
||||
|
||||
if (typeof payload.output === 'string' && payload.output.length > 0) {
|
||||
console.log('[Terminal] Writing output:', JSON.stringify(payload.output.substring(0, 100)));
|
||||
this.term?.write(payload.output);
|
||||
this.termHasOutput = true;
|
||||
this.overlayMessage = '';
|
||||
this.statusLabel = 'Live';
|
||||
}
|
||||
|
||||
if (typeof payload.offset === 'number') {
|
||||
this.offset = payload.offset;
|
||||
}
|
||||
|
||||
if (payload.status === 'starting') {
|
||||
if (!this.termHasOutput) {
|
||||
this.statusLabel = 'Connecting';
|
||||
this.overlayMessage = 'Connecting to shell...';
|
||||
}
|
||||
this.pollTimer = window.setTimeout(() => this.poll(), 120);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.status === 'closed' || payload.status === 'failed') {
|
||||
this.statusLabel = payload.status === 'failed' ? 'Failed' : 'Closed';
|
||||
this.overlayMessage = payload.error || 'Shell session ended.';
|
||||
this.canReconnect = true;
|
||||
this.sessionId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusLabel = 'Live';
|
||||
this.pollTimer = window.setTimeout(() => this.poll(), 120);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.statusLabel = 'Disconnected';
|
||||
this.overlayMessage = error.message || 'Terminal connection lost.';
|
||||
this.canReconnect = true;
|
||||
this.sessionId = null;
|
||||
});
|
||||
},
|
||||
|
||||
queueResize() {
|
||||
if (!this.term || !this.fitAddon) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = window.setTimeout(() => {
|
||||
this.fitAddon.fit();
|
||||
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(this.buildUrl(config.resizeUrlTemplate), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cols: this.term.cols,
|
||||
rows: this.term.rows,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}, 120);
|
||||
},
|
||||
|
||||
async sendCommand(command) {
|
||||
const value = String(command || '').trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.commandLine = '';
|
||||
this.focusCommandInput();
|
||||
|
||||
this.term?.write('\r\n');
|
||||
this.writePrompt(value);
|
||||
|
||||
try {
|
||||
const response = await fetch(config.runUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
command: value,
|
||||
path: this.currentPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const output = typeof payload.output === 'string'
|
||||
? payload.output
|
||||
: (typeof payload.error === 'string' ? payload.error : '');
|
||||
|
||||
if (typeof payload.cwd === 'string' && payload.cwd.length > 0) {
|
||||
this.currentPath = payload.cwd;
|
||||
this.currentPathLabel = this.currentPath === '/'
|
||||
? `/home/${config.username ?? ''}`
|
||||
: `/home/${config.username ?? ''}${this.currentPath}`;
|
||||
}
|
||||
|
||||
if (payload.clear) {
|
||||
this.term?.reset();
|
||||
}
|
||||
|
||||
if (output) {
|
||||
this.term?.write(`${output.replace(/\n/g, '\r\n')}\r\n`);
|
||||
}
|
||||
|
||||
if (!response.ok && !output) {
|
||||
this.term?.write('\x1b[31mCommand failed.\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
this.writePrompt();
|
||||
} catch (error) {
|
||||
this.term?.write(`\x1b[31m${error.message || 'Command failed.'}\x1b[0m\r\n`);
|
||||
this.writePrompt();
|
||||
}
|
||||
},
|
||||
|
||||
submitCommand() {
|
||||
const command = String(this.commandLine || '').trim();
|
||||
if (!command) {
|
||||
this.term?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendCommand(command);
|
||||
},
|
||||
|
||||
async restart() {
|
||||
this.shutdown();
|
||||
this.term?.reset();
|
||||
this.pendingInput = '';
|
||||
this.commandLine = '';
|
||||
this.offset = 0;
|
||||
this.terminalActive = false;
|
||||
this.termHasOutput = false;
|
||||
await this.start();
|
||||
},
|
||||
|
||||
focusTerminal() {
|
||||
console.log('[Terminal] focusTerminal called');
|
||||
if (this.term) {
|
||||
this.terminalActive = true;
|
||||
this.$refs.terminal?.focus();
|
||||
this.term.focus();
|
||||
console.log('[Terminal] terminalActive set to true');
|
||||
}
|
||||
},
|
||||
|
||||
focusCommandInput() {
|
||||
this.$nextTick(() => this.$refs.commandInput?.focus());
|
||||
},
|
||||
|
||||
shutdown(keepalive = false) {
|
||||
if (this.pollTimer) {
|
||||
window.clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = this.sessionId;
|
||||
this.sessionId = null;
|
||||
this.terminalActive = false;
|
||||
|
||||
fetch(String(config.closeUrlTemplate || '').replace('__SESSION__', sessionId), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
keepalive,
|
||||
}).catch(() => {});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user