mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
Three of these were not branding at all — they were live breakage, because the server was renamed and its consumers were not: - dashboard.html parsed `ClickHouseAsyncMetrics_` while PrometheusMetricsWriter emits `DatastoreAsyncMetrics_`. Paste the Prometheus output into the metrics editor and it matched nothing. - trace-visualizer queried `attribute['clickhouse.thread_id']` while OpenTelemetryTraceContext records `datastore.thread_id`. The trace tab found no spans. - both visualizers loaded `js/query-clickhouse.js`; the file on disk is `query-datastore.js`, so the script 404'd and the query function never existed. The rest is the name: titles, navbar brands, the status line a user reads after connecting, links into upstream's repo and docs site, their Cloud badges, and tests-visualizer pulling a logo from their CDN and querying their public demo server. Local symbols and DOM ids went with them (queryDatastore, parseDatastoreTrace, #tabDatastore). LICENSE, NOTICE and every copyright notice are untouched — no removed line in this diff carries a ©, an SPDX id or an attribution. `clickhouse_lexer_*` in docs.html STAYS, and a comment at the embed site says why: those export names are baked into the base64 WebAssembly payload, so the JS must match the blob. Renaming means renaming the C API, rebuilding Lexer.wasm, re-embedding it and updating the calls in one change; rename either side alone and syntax highlighting fails silently. Co-authored-by: Hanzo Dev <[email protected]>
409 lines
15 KiB
HTML
409 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6I2ZmMH0ub3tmaWxsOiMwMDB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4K">
|
|
<title>Datastore 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%;
|
|
}
|
|
|
|
/* Dark themed scrollbar */
|
|
.xterm-viewport::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
.xterm-viewport::-webkit-scrollbar-track {
|
|
background: #1e1e1e;
|
|
}
|
|
.xterm-viewport::-webkit-scrollbar-thumb {
|
|
background: #555;
|
|
border-radius: 4px;
|
|
}
|
|
.xterm-viewport::-webkit-scrollbar-thumb:hover {
|
|
background: #777;
|
|
}
|
|
.xterm-viewport {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #555 #1e1e1e;
|
|
}
|
|
</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 = '';
|
|
|
|
// Username can be supplied via URL for convenience; the password must never be passed via URL,
|
|
// because URLs end up in browser history, referrer headers, server access logs, and proxy logs.
|
|
// The password is collected interactively or, when embedded in an iframe, sent in via postMessage.
|
|
var params = new URLSearchParams(window.location.search);
|
|
/// Empty user means "let the server pick its configured default user", which is not necessarily named "default".
|
|
var currentUser = params.get('user') || '';
|
|
var currentPassword = '';
|
|
var userFromURL = params.has('user');
|
|
var inIframe = (window.parent !== window);
|
|
var waitingForCredentials = false;
|
|
// First connection attempt uses the URL-supplied user (or `default`) with empty password.
|
|
// On failure we fall back to the interactive prompt or postMessage-supplied credentials.
|
|
var initialAutoAuth = true;
|
|
|
|
// Trusted parent origin learned from the first valid postMessage we receive.
|
|
// We need this because postMessage's targetOrigin must equal the parent's actual origin,
|
|
// and cross-origin restrictions prevent reading window.parent.location.
|
|
var trustedParentOrigin = null;
|
|
|
|
function isTrustedParentOrigin(origin) {
|
|
if (origin === window.location.origin) return true;
|
|
try {
|
|
var u = new URL(origin);
|
|
if (u.protocol !== 'https:') return false;
|
|
var h = u.hostname;
|
|
return h === 'hanzo.ai'
|
|
|| h === 'hanzo.ai'
|
|
|| h.endsWith('.hanzo.ai')
|
|
|| h.endsWith('.hanzo.ai');
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getWebSocketURL() {
|
|
var loc = window.location;
|
|
var protocol = loc.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return protocol + '//' + loc.host + '/webterminal';
|
|
}
|
|
|
|
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: '#ffffff',
|
|
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();
|
|
|
|
// Escape key: when inside an iframe (playground), notify parent to deactivate terminal
|
|
if (window.parent !== window) {
|
|
term.attachCustomKeyEventHandler(function(e) {
|
|
if (e.type === 'keydown' && e.key === 'Escape') {
|
|
if (trustedParentOrigin) {
|
|
window.parent.postMessage({type: 'webterminal-escape'}, trustedParentOrigin);
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
window.addEventListener('resize', function() {
|
|
fitAddon.fit();
|
|
});
|
|
|
|
var container = document.getElementById('terminal-container');
|
|
if (window.ResizeObserver) {
|
|
new ResizeObserver(function() {
|
|
// Skip if container has no usable size (e.g. parent panel is hidden)
|
|
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
|
fitAddon.fit();
|
|
}
|
|
}).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;
|
|
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();
|
|
var opened = false;
|
|
|
|
try {
|
|
ws = new WebSocket(url);
|
|
} catch (e) {
|
|
promptCredentials('Connection failed: ' + e.message);
|
|
return;
|
|
}
|
|
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.onopen = function() {
|
|
opened = true;
|
|
// Send credentials as the first message (avoids leaking password in URL).
|
|
// The "user" field is omitted when empty so the server falls back to
|
|
// its default user (which is not necessarily named "default").
|
|
const authMessage = { type: 'auth', password: currentPassword };
|
|
if (currentUser) {
|
|
authMessage.user = currentUser;
|
|
}
|
|
ws.send(JSON.stringify(authMessage));
|
|
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(event) {
|
|
if (!opened || event.code === 1008) {
|
|
// Handshake failed or server rejected authentication (close code 1008)
|
|
ws = null;
|
|
term.write('\r\x1b[2K');
|
|
if (initialAutoAuth) {
|
|
initialAutoAuth = false;
|
|
if (inIframe && !userFromURL) {
|
|
// Embedded without URL credentials: defer to parent via postMessage.
|
|
waitingForCredentials = true;
|
|
return;
|
|
}
|
|
}
|
|
promptCredentials();
|
|
} else {
|
|
// Normal disconnect after session was active
|
|
if (window.parent !== window) {
|
|
// Inside an iframe - notify parent to deactivate terminal
|
|
if (trustedParentOrigin) {
|
|
window.parent.postMessage({type: 'webterminal-closed'}, trustedParentOrigin);
|
|
}
|
|
} else {
|
|
window.location.href = 'https://github.com/hanzoai/datastore';
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onerror = function() {
|
|
// onerror always fires before onclose, handled there
|
|
};
|
|
}
|
|
|
|
// Listen for messages from the parent (credentials, refit requests).
|
|
// Accepted origins: same-origin, *.hanzo.ai, *.hanzo.ai (https only).
|
|
// A 'webterminal-hello' handshake lets the parent register its origin so the iframe
|
|
// can post back even when the parent is cross-origin.
|
|
window.addEventListener('message', function(e) {
|
|
// Only accept messages from the embedding parent window. Without this, any window
|
|
// from an allowed origin that obtains a reference to us (e.g. via window.open)
|
|
// could drive the credentials/refit handlers.
|
|
if (e.source !== window.parent) return;
|
|
if (!isTrustedParentOrigin(e.origin)) return;
|
|
if (!e.data) return;
|
|
|
|
// Remember the parent origin so outbound posts (escape/closed) can reach it.
|
|
trustedParentOrigin = e.origin;
|
|
|
|
if (e.data.type === 'webterminal-hello') {
|
|
return;
|
|
} else if (e.data.type === 'webterminal-credentials' && waitingForCredentials) {
|
|
waitingForCredentials = false;
|
|
// Parent-supplied credentials are explicit; subsequent failures should prompt
|
|
// interactively rather than wait for another (one-shot) parent message.
|
|
initialAutoAuth = false;
|
|
currentUser = e.data.user || '';
|
|
currentPassword = e.data.password || '';
|
|
userFromURL = true;
|
|
attemptConnect();
|
|
} else if (e.data.type === 'webterminal-refit' && fitAddon && term) {
|
|
// Defer fit until the browser has finished layout after the panel becomes visible,
|
|
// otherwise fitAddon may compute a tiny column count from stale dimensions.
|
|
requestAnimationFrame(function() {
|
|
fitAddon.fit();
|
|
term.focus();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Start
|
|
initTerminal();
|
|
if (inIframe && !userFromURL) {
|
|
// The parent (e.g. `play.html`) sends `webterminal-credentials` once on iframe `load`.
|
|
// Pre-arm the receiver so that message isn't dropped if it arrives before or during
|
|
// the initial auto-auth attempt; the in-flight connection is replaced when the message
|
|
// is processed.
|
|
waitingForCredentials = true;
|
|
}
|
|
// Try to connect automatically using the URL-supplied user (or `default`) with empty password.
|
|
// If the server rejects this, `ws.onclose` falls back to the interactive prompt or, when
|
|
// embedded in an iframe without a URL-supplied user, waits for credentials via postMessage.
|
|
attemptConnect();
|
|
</script>
|
|
</body>
|
|
</html>
|