mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
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]>
297 lines
9.1 KiB
HTML
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>
|