Commit the control-panel terminal frontend (xterm) so clean builds keep it
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:
isaacclad
2026-06-26 23:00:02 +00:00
co-authored by Claude Opus 4.8
parent ff3959b734
commit e86f95ddc7
4 changed files with 584 additions and 3 deletions
+4
View File
@@ -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';
+556
View File
@@ -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(() => {});
},
};
};