Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@
.idea/
.vscode/
/package-lock.json

# Python bytecode cache
__pycache__/
*.pyc
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,34 @@ npm run build # → dist/sql.html (single file)
npm run dev # build + serve dist/ at http://localhost:8900
```

### Run locally against your own ClickHouse

`npm run local` builds the SPA and serves it as a static page on localhost:

```bash
npm run local # build + serve → open http://localhost:8900/sql
```

The app is a thin client — queries go straight from the browser to the chosen
ClickHouse — so the local server only serves the page plus a generated
`config.json`. It reads your **`~/.clickhouse-client/config.xml`** connections and
offers them as a **Saved connection** dropdown on the login screen:

- A plain connection (`hostname`/`user`/`password`) → prefills the credentials
form (cross-origin HTTP Basic to that host).
- A connection carrying clickhouse-client's OAuth keys (`oauth-url`,
`oauth-client-id`, `oauth-audience`) → an OAuth sign-in against that cluster.

You can also ignore the picker and type a host/user/password by hand (host: include
the scheme, e.g. `http://localhost:8123`; a bare host defaults to
`https://<host>:8443`).

The target ClickHouse must allow cross-origin requests — ClickHouse's HTTP
interface sends `Access-Control-Allow-Origin` for requests with an `Origin` header
by default, so a stock server works. For an **OAuth** connection you also register
`http://localhost:8900/sql` as a redirect URI with the IdP. Override the serve port
with `PORT` and the config path with `LOCAL_CH_CONFIG`. Ctrl-C stops it.

## Installing on any ClickHouse cluster

```bash
Expand Down
131 changes: 131 additions & 0 deletions build/local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Serve the built SQL browser locally, with a host picker from clickhouse-client.

The app is a thin client: in *credentials* mode its login form takes a ClickHouse
host and queries it directly (cross-origin); in OAuth mode it signs in via an IdP
and sends the bearer to the chosen cluster. So this server only serves the SPA +
a generated config.json — there's nothing to proxy and no ClickHouse to run here.

It reads your `~/.clickhouse-client/config.xml` connections and offers them as a
**Saved connection** dropdown on the login screen:
• a plain connection (hostname/user/password) → prefills the credentials form.
• a connection carrying clickhouse-client's OAuth keys (`oauth-url`,
`oauth-client-id`, optional `oauth-client-secret` for a Web client like Google,
`oauth-audience`) → an OAuth sign-in against that cluster.

npm run local # build + serve, then open http://localhost:8900/sql

For OAuth connections you also register `http://localhost:8900/sql` as a redirect
URI with the IdP and allow CORS from localhost on the cluster (see README).

Env: PORT (default 8900) · LOCAL_CH_CONFIG (default ~/.clickhouse-client/config.xml).
"""
import json
import os
import sys
import xml.etree.ElementTree as ET
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SPA = os.path.join(ROOT, "dist", "sql.html")
PORT = int(os.environ.get("PORT", "8900"))
CH_CONFIG = os.environ.get("LOCAL_CH_CONFIG") or os.path.expanduser("~/.clickhouse-client/config.xml")


def _text(conn, *names):
"""First non-empty child text among `names` (dash/underscore variants)."""
for n in names:
el = conn.find(n)
if el is not None and el.text and el.text.strip():
return el.text.strip()
return ""


def build_config():
"""Generate config.json from the clickhouse-client connections (best-effort)."""
idps, hosts, seen = [], [], set()
try:
root = ET.parse(CH_CONFIG).getroot()
except (OSError, ET.ParseError):
root = None
for conn in (root.iter("connection") if root is not None else []):
name = _text(conn, "name")
hostname = _text(conn, "hostname")
if not name or not hostname:
continue
secure = _text(conn, "secure").lower() in ("1", "true", "yes")
http_port = _text(conn, "http_port", "http-port")
scheme = "https" if secure else "http"
url = f"{scheme}://{hostname}:{http_port}" if http_port else f"{scheme}://{hostname}"
oauth_url = _text(conn, "oauth-url", "oauth_url")
oauth_client = _text(conn, "oauth-client-id", "oauth_client_id")
oauth_secret = _text(conn, "oauth-client-secret", "oauth_client_secret")
oauth_aud = _text(conn, "oauth-audience", "oauth_audience")
if oauth_url and oauth_client:
if name not in seen:
idps.append({
"id": name, "label": name, "issuer": oauth_url, "client_id": oauth_client,
# Optional: a Web-client secret (e.g. Google) for the code exchange.
# Empty → public PKCE. clickhouse-client has no such flag, so this is
# a local-only convenience key read from the same connection.
"client_secret": oauth_secret, "audience": oauth_aud,
"bearer": "access_token" if oauth_aud else "id_token",
})
seen.add(name)
hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name})
else:
hosts.append({"label": name, "url": url, "auth": "basic",
"user": _text(conn, "user"), "password": _text(conn, "password")})
return json.dumps({"basic_login": True, "idps": idps, "hosts": hosts}).encode()


CONFIG = build_config()


class Handler(BaseHTTPRequestHandler):
def _send(self, body, ctype, code=200):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)

def do_GET(self):
path = self.path.split("?", 1)[0]
if path.endswith("/config.json"):
self._send(CONFIG, "application/json; charset=utf-8")
return
if path.rstrip("/") in ("", "/sql", "/sql.html"):
try:
with open(SPA, "rb") as f:
html = f.read()
except FileNotFoundError:
self._send(b"dist/sql.html missing - run `npm run build`.\n", "text/plain", 500)
return
self._send(html, "text/html; charset=utf-8")
return
self._send(b"not found\n", "text/plain", 404)

def log_message(self, *_a): # keep the console quiet
pass


def main():
if not os.path.exists(SPA):
sys.exit("dist/sql.html not found - run `npm run build` first (or `npm run local`).")
n = json.loads(CONFIG)["hosts"]
print(
f"\n Altinity SQL Browser - local static server\n"
f" ▸ open http://localhost:{PORT}/sql\n"
f" ▸ {len(n)} saved connection(s) from {CH_CONFIG}\n"
f" ▸ Ctrl-C to stop\n"
)
try:
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
except KeyboardInterrupt:
pass


if __name__ == "__main__":
main()
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"test": "vitest run --coverage --config tests/vitest.config.ts",
"test:watch": "vitest --config tests/vitest.config.ts",
"test:e2e": "playwright test",
"dev": "node build/build.mjs && python3 -m http.server -d dist 8900"
"dev": "node build/build.mjs && python3 -m http.server -d dist 8900",
"local": "node build/build.mjs && python3 build/local.py"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
Expand Down
5 changes: 3 additions & 2 deletions src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,16 @@ export async function loadServerVersion(ctx) {
}

/**
* Load the table list grouped by database (excludes system schemas).
* Load the table list grouped by database. `system` is included (handy for
* dashboards/diagnostics); the redundant INFORMATION_SCHEMA views stay filtered.
* Returns [{ db, expanded, tables: [{name,total_rows,total_bytes,comment,columns:null}] }].
*/
export async function loadSchema(ctx) {
const sql =
'SELECT database, name, toUInt64(total_rows) AS total_rows, ' +
'toUInt64(total_bytes) AS total_bytes, comment\n' +
'FROM system.tables\n' +
"WHERE database NOT IN ('INFORMATION_SCHEMA','information_schema','system')\n" +
"WHERE database NOT IN ('INFORMATION_SCHEMA','information_schema')\n" +
'ORDER BY database, name\n' +
'FORMAT JSON';
const json = await queryJson(ctx, sql);
Expand Down
28 changes: 26 additions & 2 deletions src/net/oauth-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,25 @@ function normalizeEntry(e) {
}

/**
* Fetch config.json and normalize to `{ idps: [descriptor, ...], basicLogin }`.
* Map one raw `hosts[]` entry to a saved-connection descriptor for the login
* picker: `{ label, url, auth, user, password, idp }`. `auth` is 'oauth' (sign in
* via the named `idp`, querying `url` cross-origin) or 'basic' (prefill the
* credentials form with `url`/`user`/`password`).
*/
function normalizeHost(h) {
const e = h || {};
return {
label: e.label || e.url || '',
url: e.url || '',
auth: e.auth === 'oauth' ? 'oauth' : 'basic',
user: e.user || '',
password: e.password || '',
idp: e.idp || '',
};
}

/**
* Fetch config.json and normalize to `{ idps: [descriptor, ...], basicLogin, hosts }`.
* Accepts a list (`{ idps: [...] }`) or a single bare object (legacy) wrapped
* into one entry. An IdP-less config (no `idps`, no `issuer`) is valid — it
* describes a credentials-only deployment, so `idps` comes back empty rather
Expand All @@ -86,7 +104,13 @@ export async function loadConfigDoc(fetchFn, basePath = '') {
const list = Array.isArray(cfg.idps)
? cfg.idps
: (cfg.issuer || cfg.client_id) ? [cfg] : [];
return { idps: list.map(normalizeEntry), basicLogin: cfg.basic_login !== false };
return {
idps: list.map(normalizeEntry),
basicLogin: cfg.basic_login !== false,
// Optional saved-connection list for the login host picker (npm run local
// fills it from ~/.clickhouse-client/config.xml). Empty when absent.
hosts: Array.isArray(cfg.hosts) ? cfg.hosts.map(normalizeHost) : [],
};
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ body {
font-family: inherit; transition: border-color .12s, box-shadow .12s;
}
.login-input.mono { font-family: var(--mono); font-size: 12.5px; }
.login-picker {
width: 100%; height: 38px; padding: 0 11px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: 8px; color: var(--fg); font-size: 12.5px; outline: none;
font-family: var(--mono); cursor: pointer; transition: border-color .12s, box-shadow .12s;
}
.login-picker:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); }
.login-input::placeholder { color: var(--fg-faint); }
.login-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); }
.login-input-wrap { position: relative; }
Expand Down
15 changes: 11 additions & 4 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function createApp(env = {}) {
app.authMode = 'oauth';
chCtx.origin = loc.origin;
chCtx.authConfirmed = false; // a fresh sign-in starts unconfirmed again
['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp',
['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp', 'oauth_origin',
'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin'].forEach((k) => ss.removeItem(k));
}
app.setTokens = setTokens;
Expand All @@ -152,8 +152,13 @@ export function createApp(env = {}) {
app.showLogin = (msg) => renderLogin(app, msg);

// --- OAuth -------------------------------------------------------------
async function login(idpId) {
async function login(idpId, targetOrigin) {
if (idpId) selectIdp(idpId);
// A picked saved-connection can target another cluster: stash its origin so
// the rebuilt chCtx (after the redirect reload) POSTs the bearer there.
// Survives the redirect like oauth_state/oauth_idp; cleared for serving-host SSO.
if (targetOrigin) ss.setItem('oauth_origin', targetOrigin);
else ss.removeItem('oauth_origin');
const cfg = await resolveConfig();
const { verifier, challenge } = await generatePKCE(cryptoObj);
const state = randomState(cryptoObj);
Expand Down Expand Up @@ -207,7 +212,9 @@ export function createApp(env = {}) {
fetch: fetchFn,
// Where queries POST: the serving origin for OAuth, or the (possibly
// cross-origin) target chosen at credential sign-in for basic mode.
origin: app.authMode === 'basic' ? (ss.getItem('ch_basic_origin') || loc.origin) : loc.origin,
origin: app.authMode === 'basic'
? (ss.getItem('ch_basic_origin') || loc.origin)
: (ss.getItem('oauth_origin') || loc.origin),
// Flips true after the first 2xx; gates whether a later 401/403 is treated
// as a sign-in failure (only before auth is confirmed) or a query error.
authConfirmed: false,
Expand Down Expand Up @@ -779,7 +786,7 @@ export function createApp(env = {}) {
selectTab: (id) => selectTab(app, id),
closeTab: (id) => closeTab(app, id),
loadIntoNewTab: (name, sql, savedId, chart) => loadIntoNewTab(app, name, sql, savedId, chart),
login: (idpId) => login(idpId),
login: (idpId, targetOrigin) => login(idpId, targetOrigin),
connect,
share,
copyResult,
Expand Down
Loading
Loading