Files
datastore/programs/server/webterminal.html
T
Alexey MilovidovandClaude Opus 4.6 ab7bc27ae3 Bundle xterm.js libraries as embedded static files
Embed xterm.js 5.5.0, addon-fit 0.10.0, and addon-web-links 0.11.0
(minified JS and CSS) into the server binary, served at /js/*.
This removes the dependency on CDN and makes the web terminal
page fully self-contained, like the dashboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-21 14:28:05 +01:00

297 lines
9.1 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6I2ZmMH0ub3tmaWxsOiMwMDB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4K">
<title>ClickHouse Web Terminal</title>
<!-- Code Style:
Do not use any JavaScript or CSS frameworks or preprocessors (except xterm.js for terminal emulation).
This HTML page should not require any build systems (node.js, npm, gulp, etc.)
This HTML page should not be minified, instead it should be reasonably minimalistic by itself.
This UI should look as lightweight, clean and fast as possible.
-->
<link rel="stylesheet" href="/js/xterm.min.css">
<script src="/js/xterm.min.js"></script>
<script src="/js/addon-fit.min.js"></script>
<script src="/js/addon-web-links.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
overflow: hidden;
height: 100vh;
width: 100vw;
}
#terminal-container {
height: 100%;
width: 100%;
}
/* Hide scrollbar but keep scrollable */
.xterm-viewport::-webkit-scrollbar {
display: none;
}
.xterm-viewport {
scrollbar-width: none;
}
</style>
</head>
<body>
<div id="terminal-container"></div>
<script>
'use strict';
var term = null;
var fitAddon = null;
var ws = null;
var dataHandler = null;
// State: 'connecting', 'prompt_user', 'prompt_password', 'active', 'closed'
var state = 'connecting';
var inputBuffer = '';
// Credentials from URL or defaults
var params = new URLSearchParams(window.location.search);
var currentUser = params.get('user') || 'default';
var currentPassword = params.get('password') || '';
var userFromURL = params.has('user');
function getWebSocketURL(user, password) {
var loc = window.location;
var protocol = loc.protocol === 'https:' ? 'wss:' : 'ws:';
var url = protocol + '//' + loc.host + '/webterminal';
var p = [];
if (user) p.push('user=' + encodeURIComponent(user));
if (password) p.push('password=' + encodeURIComponent(password));
if (p.length > 0) url += '?' + p.join('&');
return url;
}
function initTerminal() {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#000',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
selectionBackground: '#264f78',
black: '#000',
red: '#f44747',
green: '#6a9955',
yellow: '#d7ba7d',
blue: '#569cd6',
magenta: '#c586c0',
cyan: '#4ec9b0',
white: '#d4d4d4',
brightBlack: '#808080',
brightRed: '#f44747',
brightGreen: '#6a9955',
brightYellow: '#d7ba7d',
brightBlue: '#569cd6',
brightMagenta: '#c586c0',
brightCyan: '#4ec9b0',
brightWhite: '#ffffff'
},
allowProposedApi: true,
scrollback: 10000,
});
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.open(document.getElementById('terminal-container'));
fitAddon.fit();
window.addEventListener('resize', function() {
fitAddon.fit();
sendResize();
});
var container = document.getElementById('terminal-container');
if (window.ResizeObserver) {
new ResizeObserver(function() {
fitAddon.fit();
sendResize();
}).observe(container);
}
// Single onData handler that dispatches based on state
term.onData(function(data) {
if (state === 'prompt_user' || state === 'prompt_password') {
handlePromptInput(data);
} else if (state === 'active') {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
}
});
term.onResize(function() {
sendResize();
});
term.focus();
}
function sendResize() {
if (ws && ws.readyState === WebSocket.OPEN && term) {
ws.send(JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows
}));
}
}
function handlePromptInput(data) {
for (var i = 0; i < data.length; i++) {
var ch = data[i];
var code = data.charCodeAt(i);
if (ch === '\r' || ch === '\n') {
// Enter - submit
term.write('\r\n');
var value = inputBuffer;
inputBuffer = '';
if (state === 'prompt_user') {
currentUser = value || 'default';
promptPassword();
} else if (state === 'prompt_password') {
currentPassword = value;
attemptConnect();
}
return;
} else if (code === 127 || code === 8) {
// Backspace
if (inputBuffer.length > 0) {
inputBuffer = inputBuffer.slice(0, -1);
if (state === 'prompt_user') {
term.write('\b \b');
}
// For password, nothing visible to erase
}
} else if (code === 3) {
// Ctrl+C - reset to user prompt
term.write('^C\r\n');
inputBuffer = '';
promptUser();
return;
} else if (code >= 32) {
// Printable character
inputBuffer += ch;
if (state === 'prompt_user') {
term.write(ch);
}
// For password, don't echo
}
}
}
function promptUser() {
state = 'prompt_user';
inputBuffer = '';
term.write('Username: ');
}
function promptPassword() {
state = 'prompt_password';
inputBuffer = '';
term.write('Password: ');
}
function promptCredentials(message) {
if (message) {
term.write('\x1b[31m' + message + '\x1b[0m\r\n');
}
if (userFromURL) {
// User was given in URL, only ask for password
promptPassword();
} else {
promptUser();
}
}
function attemptConnect() {
state = 'connecting';
term.write('\x1b[90mConnecting...\x1b[0m');
if (ws) {
ws.onclose = null;
ws.onerror = null;
ws.close();
ws = null;
}
var url = getWebSocketURL(currentUser, currentPassword);
var opened = false;
try {
ws = new WebSocket(url);
} catch (e) {
promptCredentials('Connection failed: ' + e.message);
return;
}
ws.binaryType = 'arraybuffer';
ws.onopen = function() {
opened = true;
state = 'active';
// Erase the "Connecting..." message so PTY output starts on a clean line
term.write('\r\x1b[2K');
sendResize();
};
ws.onmessage = function(event) {
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data));
} else {
term.write(event.data);
}
};
ws.onclose = function() {
if (!opened) {
// Handshake failed - likely authentication error
ws = null;
term.write('\r\x1b[2K');
promptCredentials('Authentication failed.');
} else {
// Normal disconnect after session was active
ws = null;
state = 'closed';
term.write('\r\n\x1b[90m--- Session ended ---\x1b[0m\r\n\r\n');
// Allow reconnecting
userFromURL = false;
promptCredentials(null);
}
};
ws.onerror = function() {
// onerror always fires before onclose, handled there
};
}
// Start
initTerminal();
attemptConnect();
</script>
</body>
</html>