From 9b6be23434fe76fef506b94fb58bd93cc057f12f Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 13:44:36 +0200 Subject: [PATCH 1/6] feat(dist): curl|sh install for the local runner + CI releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distribute the Python local runner as a downloadable bundle so users can run it with just python3 — no clone, no Node. - install.sh (root): POSIX `curl | sh` installer. Resolves the latest release, downloads altinity-sql-browser.tar.gz, verifies the sha256, extracts to ~/.altinity-sql-browser, installs a launcher in ~/.local/bin. (Distinct from deploy/install.sh, which deploys onto a ClickHouse cluster.) - build/bundle.sh: assemble the release tarball (prebuilt sql.html + local.py + config.example.xml + self-resolving run.sh + VERSION/README) with a sha256. - build/local.py: discover sql.html across layouts — $SQL_BROWSER_SPA, then next-to-the-script (bundle), then ../dist (dev) — so the same runner works in the bundle and in a checkout. - deploy/clickhouse-client-config.example.xml: a sample connections file shipped in the bundle as config.example.xml; it never replaces the user's real ~/.clickhouse-client/config.xml. - .github/workflows/release.yml: on a v* tag → gate + bundle + gh release. - .github/workflows/ci.yml: new `bundle` job smoke-tests the artifact on every PR (extract → boot the runner → fetch /sql + /config.json) and shellchecks the installer. - README: curl|sh quick-install + a Releasing section. Verified locally: bundle builds, extracts, boots under the sample config (SPA found via bundle layout, config.json hosts parsed), and the dev layout still serves from ../dist. Coverage gate unchanged (952 passing). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MUc6UU9qs5J3u7qoy5YoNN --- .github/workflows/ci.yml | 35 ++++++++ .github/workflows/release.yml | 33 +++++++ README.md | 32 ++++++- build/bundle.sh | 67 ++++++++++++++ build/local.py | 24 ++++- deploy/clickhouse-client-config.example.xml | 59 +++++++++++++ install.sh | 98 +++++++++++++++++++++ 7 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 build/bundle.sh create mode 100644 deploy/clickhouse-client-config.example.xml create mode 100755 install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9558289..984e74d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,41 @@ jobs: path: dist/sql.html retention-days: 14 + # Release-bundle smoke test: assemble the curl|sh artifact, extract it, and boot + # the zero-dep Python runner exactly as an end user would — proving the bundle + # layout, the SPA-path discovery, and config.json generation all work before a + # tag ever cuts a real release. + bundle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm install --no-audit --no-fund + - name: Build release bundle + run: build/bundle.sh + - name: Extract + boot the runner (as a user would) + run: | + set -euo pipefail + tmp=$(mktemp -d) + tar -C "$tmp" -xzf dist/altinity-sql-browser.tar.gz --strip-components=1 + for f in sql.html local.py config.example.xml run.sh VERSION; do + test -f "$tmp/$f" || { echo "missing $f in bundle" >&2; exit 1; } + done + python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$tmp/local.py" + LOCAL_CH_CONFIG="$tmp/config.example.xml" PORT=8901 "$tmp/run.sh" & + pid=$! + for i in $(seq 1 20); do curl -fsS "http://localhost:8901/sql" >/dev/null 2>&1 && break; sleep 0.5; done + curl -fsS "http://localhost:8901/sql" >/dev/null + curl -fsS "http://localhost:8901/config.json" \ + | python3 -c "import sys,json; assert json.load(sys.stdin)['hosts'], 'no hosts parsed'" + kill "$pid" + - name: Lint the installer (shellcheck) + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck install.sh build/bundle.sh + # Real-browser regression tests (Playwright). Runs on Linux runners where the # Gecko/Chromium binaries launch normally — separate from the unit suite so a # missing browser binary can't mask a unit failure, and vice versa. The diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4e59e06 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: release + +# Cut a release by pushing a tag, e.g.: git tag v0.1.0 && git push origin v0.1.0 +on: + push: + tags: ['v*'] + +permissions: + contents: write # create the GitHub Release + upload assets + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm install --no-audit --no-fund + - name: Test (coverage gate) + run: npm test + - name: Build release bundle + run: build/bundle.sh "${GITHUB_REF_NAME#v}" + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --generate-notes \ + dist/altinity-sql-browser.tar.gz \ + dist/altinity-sql-browser.tar.gz.sha256 \ + dist/sql.html diff --git a/README.md b/README.md index 5899d5e..047e2fe 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,22 @@ 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: +**Install (no clone, no Node — just `python3`):** + +```bash +curl -fsSL https://raw.githubusercontent.com/Altinity/altinity-sql-browser/main/install.sh | sh +altinity-sql-browser # serve → open http://localhost:8900/sql +``` + +This downloads the latest [release](https://github.com/Altinity/altinity-sql-browser/releases) +bundle (the prebuilt single-file SPA + the zero-dependency Python runner) into +`~/.altinity-sql-browser` and installs a launcher in `~/.local/bin`. Overrides: +`ASB_VERSION` (tag to install), `ASB_HOME`, `ASB_BIN`. The bundle ships a sample +**`config.example.xml`** (a clickhouse-client connections template — it does *not* +touch your real `~/.clickhouse-client/config.xml`); try it with +`LOCAL_CH_CONFIG=~/.altinity-sql-browser/config.example.xml altinity-sql-browser`. + +**From a checkout** (also builds the SPA, needs Node): ```bash npm run local # build + serve → open http://localhost:8900/sql @@ -414,6 +429,21 @@ The harness (`tests/e2e/`) serves the repo over HTTP and imports the actual source as native ESM — no bundling, always current. It is **not** part of `npm test` or the coverage gate. +## Releasing + +Releases are cut by pushing a version tag — `.github/workflows/release.yml` then +runs the coverage gate, assembles the bundle, and publishes a GitHub Release: + +```bash +git tag v0.1.0 && git push origin v0.1.0 +``` + +The release attaches `altinity-sql-browser.tar.gz` (+ `.sha256`) and the raw +`sql.html`. The bundle is built by `build/bundle.sh` (also runnable locally), and +every PR smoke-tests it in CI (`bundle` job: extract → boot the runner → fetch +`/sql` + `/config.json`). The `curl | sh` `install.sh` resolves the latest tag and +installs that artifact. + ## License Apache-2.0. diff --git a/build/bundle.sh b/build/bundle.sh new file mode 100755 index 0000000..52a9c19 --- /dev/null +++ b/build/bundle.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Assemble the release bundle for the local runner: +# dist/altinity-sql-browser.tar.gz (+ .sha256) +# +# Contents (under a single top dir so `tar --strip-components=1` is clean): +# altinity-sql-browser/ +# sql.html — the prebuilt single-file SPA +# local.py — the zero-dep Python runner (serves SPA + config.json) +# config.example.xml — sample clickhouse-client connections (template) +# run.sh — self-resolving launcher (python3 local.py) +# VERSION +# README.txt +# +# Builds the SPA first. Used by .github/workflows/release.yml and runnable +# locally. Pass a version as $1, else it's read from package.json. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="${1:-$(node -p "require('$ROOT/package.json').version")}" +OUT="$ROOT/dist" +STAGE="$OUT/bundle/altinity-sql-browser" + +echo "==> Building SPA" +node "$ROOT/build/build.mjs" + +echo "==> Staging bundle ($VERSION)" +rm -rf "$OUT/bundle" +mkdir -p "$STAGE" +cp "$OUT/sql.html" "$STAGE/sql.html" +cp "$ROOT/build/local.py" "$STAGE/local.py" +cp "$ROOT/deploy/clickhouse-client-config.example.xml" "$STAGE/config.example.xml" +printf '%s\n' "$VERSION" > "$STAGE/VERSION" + +cat > "$STAGE/run.sh" <<'EOF' +#!/bin/sh +# Launch the Altinity SQL Browser local runner. Serves the bundled sql.html and a +# config.json generated from your ~/.clickhouse-client/config.xml. +# PORT=8900 LOCAL_CH_CONFIG=~/.clickhouse-client/config.xml ./run.sh +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$DIR/local.py" "$@" +EOF +chmod +x "$STAGE/run.sh" + +cat > "$STAGE/README.txt" <<'EOF' +Altinity SQL Browser — local runner +=================================== + +Requires only python3 (preinstalled on macOS/Linux). + + ./run.sh # serve http://localhost:8900/sql + +It reads your ~/.clickhouse-client/config.xml and offers each connection in the +login picker. To try the bundled sample instead (your real config is untouched): + + LOCAL_CH_CONFIG=./config.example.xml ./run.sh + +Env: PORT (default 8900), LOCAL_CH_CONFIG, SQL_BROWSER_SPA. + +Source & docs: https://github.com/Altinity/altinity-sql-browser +EOF + +echo "==> Archiving" +TARBALL="altinity-sql-browser.tar.gz" +tar -C "$OUT/bundle" -czf "$OUT/$TARBALL" altinity-sql-browser +( cd "$OUT" && { sha256sum "$TARBALL" 2>/dev/null || shasum -a 256 "$TARBALL"; } > "$TARBALL.sha256" ) +echo " $OUT/$TARBALL" +echo " $OUT/$TARBALL.sha256" diff --git a/build/local.py b/build/local.py index f5e4b2f..13edb08 100644 --- a/build/local.py +++ b/build/local.py @@ -23,7 +23,8 @@ 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). +Env: PORT (default 8900) · LOCAL_CH_CONFIG (default ~/.clickhouse-client/config.xml) + · SQL_BROWSER_SPA (override the sql.html path). """ import json import os @@ -31,8 +32,25 @@ 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") +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _find_spa(): + """Locate the built sql.html across both layouts (explicit override wins): + • $SQL_BROWSER_SPA — explicit path + • /sql.html — release bundle (local.py + sql.html together) + • /../dist/sql.html — dev / in-repo checkout + Returns the first that exists, else the dev path (so the missing-file error + names the place a contributor expects to build into).""" + env = os.environ.get("SQL_BROWSER_SPA") + if env: + return env + bundle = os.path.join(HERE, "sql.html") + dev = os.path.join(HERE, "..", "dist", "sql.html") + return bundle if os.path.exists(bundle) else dev + + +SPA = _find_spa() PORT = int(os.environ.get("PORT", "8900")) CH_CONFIG = os.environ.get("LOCAL_CH_CONFIG") or os.path.expanduser("~/.clickhouse-client/config.xml") diff --git a/deploy/clickhouse-client-config.example.xml b/deploy/clickhouse-client-config.example.xml new file mode 100644 index 0000000..b66563a --- /dev/null +++ b/deploy/clickhouse-client-config.example.xml @@ -0,0 +1,59 @@ + + + + + + + my-cluster + clickhouse.example.com + 1 + default + + + + + + localhost + localhost + 8123 + default + + + + + dev-tenant + support.tenant.dev.altinity.cloud + 1 + admin + 1 + + + + + sso-cluster + clickhouse.example.com + 1 + https://accounts.google.com + YOUR_CLIENT_ID + + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8ad69f5 --- /dev/null +++ b/install.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# Altinity SQL Browser — LOCAL RUNNER installer (curl | sh). +# +# Downloads the latest release bundle (prebuilt single-file SPA + the zero-dep +# Python runner) and installs a launcher. Needs only python3 (preinstalled on +# macOS/Linux) plus curl or wget. +# +# curl -fsSL https://raw.githubusercontent.com/Altinity/altinity-sql-browser/main/install.sh | sh +# +# This installs the LOCAL runner (point the browser at your own clusters from +# ~/.clickhouse-client/config.xml). To deploy the app ONTO a ClickHouse cluster +# instead, use deploy/install.sh in the repo. +# +# Env overrides: +# ASB_VERSION release tag to install (default: latest) +# ASB_HOME install dir (default: ~/.altinity-sql-browser) +# ASB_BIN launcher dir (default: ~/.local/bin) +set -eu + +REPO="Altinity/altinity-sql-browser" +ASSET="altinity-sql-browser.tar.gz" +ASB_HOME="${ASB_HOME:-$HOME/.altinity-sql-browser}" +ASB_BIN="${ASB_BIN:-$HOME/.local/bin}" + +say() { printf '%s\n' "$*"; } +err() { printf 'error: %s\n' "$*" >&2; exit 1; } + +# --- prerequisites --- +command -v python3 >/dev/null 2>&1 || err "python3 not found — install Python 3 and re-run." +if command -v curl >/dev/null 2>&1; then + dl() { curl -fsSL "$1"; } # to stdout + dlo() { curl -fsSL -o "$1" "$2"; } # to file +elif command -v wget >/dev/null 2>&1; then + dl() { wget -qO- "$1"; } + dlo() { wget -qO "$1" "$2"; } +else + err "need curl or wget." +fi + +# --- resolve version --- +TAG="${ASB_VERSION:-}" +if [ -z "$TAG" ]; then + say "==> Resolving latest release…" + TAG=$(dl "https://api.github.com/repos/$REPO/releases/latest" \ + | grep -m1 '"tag_name"' \ + | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') || true + [ -n "$TAG" ] || err "could not resolve the latest release (set ASB_VERSION=vX.Y.Z)." +fi +say "==> Installing $REPO @ $TAG" + +BASE="https://github.com/$REPO/releases/download/$TAG" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +say "==> Downloading $ASSET" +dlo "$TMP/$ASSET" "$BASE/$ASSET" || err "download failed: $BASE/$ASSET" + +# --- verify checksum when published --- +if dlo "$TMP/$ASSET.sha256" "$BASE/$ASSET.sha256" 2>/dev/null; then + if command -v sha256sum >/dev/null 2>&1; then + ( cd "$TMP" && sha256sum -c "$ASSET.sha256" >/dev/null ) || err "checksum mismatch." + elif command -v shasum >/dev/null 2>&1; then + ( cd "$TMP" && shasum -a 256 -c "$ASSET.sha256" >/dev/null ) || err "checksum mismatch." + else + say " (no sha256 tool; skipping verification)" + fi + say " checksum OK" +else + say " (no checksum published; skipping verification)" +fi + +# --- install --- +say "==> Installing to $ASB_HOME" +rm -rf "$ASB_HOME" +mkdir -p "$ASB_HOME" +tar -xzf "$TMP/$ASSET" -C "$ASB_HOME" --strip-components=1 + +mkdir -p "$ASB_BIN" +LAUNCH="$ASB_BIN/altinity-sql-browser" +cat > "$LAUNCH" </dev/null || echo "$TAG"). Launcher: $LAUNCH" +case ":$PATH:" in + *":$ASB_BIN:"*) ;; + *) say ""; say "NOTE: $ASB_BIN is not on your PATH. Add it:"; say " export PATH=\"$ASB_BIN:\$PATH\"" ;; +esac +say "" +say "Run it:" +say " altinity-sql-browser # reads ~/.clickhouse-client/config.xml → http://localhost:8900/sql" +say "" +say "No connections configured yet? A sample is bundled (your real config is untouched):" +say " LOCAL_CH_CONFIG=$ASB_HOME/config.example.xml altinity-sql-browser" +say " # or copy it as a starting point: cp $ASB_HOME/config.example.xml ~/.clickhouse-client/config.xml" From 2e517741778266414ec845ca9438381da2cd750b Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 14:36:09 +0200 Subject: [PATCH 2/6] feat(dist): merge config.xml + sql-browser.xml; ship verified public demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the bundle from real, verified connections and adds a second connection source so a fresh install has something to connect to. - build/local.py: read connections from MULTIPLE files and merge, de-duped by name (first wins): ~/.clickhouse-client/config.xml (the user's own, wins on a clash), then ~/.clickhouse-client/sql-browser.xml (installed demos), then a sql-browser.xml next to the runner (run-from-bundle). LOCAL_CH_CONFIG still overrides to a single explicit file. Document that the HTTP port comes from or the 8443/8123 default — the native (9440/9000) is never used to derive it. - deploy/sql-browser.xml: replaces the placeholder example with PUBLIC demo clusters verified reachable over the HTTP interface (antalya, altinity-demo, clickhouse-sql) + a commented OAuth template (no real secret). Dropped play.clickhouse.com: it exposes no HTTP query interface (native-only), so the browser can't reach it. - install.sh: write the sample to ~/.clickhouse-client/sql-browser.xml (backing up any existing one); never touches config.xml. - build/bundle.sh + ci.yml: ship/smoke-test sql-browser.xml; the CI boot test now relies on the runner discovering it next to local.py (no LOCAL_CH_CONFIG). - README: document the merge + the http-port rule. Verified: merge keeps config.xml on a name clash (5 real → 5, demos deduped); fresh machine (bundled file only) → 3 demos; gate 952 passing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MUc6UU9qs5J3u7qoy5YoNN --- .github/workflows/ci.yml | 6 +- README.md | 12 ++-- build/bundle.sh | 20 ++++--- build/local.py | 56 +++++++++++++---- deploy/clickhouse-client-config.example.xml | 59 ------------------ deploy/sql-browser.xml | 66 +++++++++++++++++++++ install.sh | 17 ++++-- 7 files changed, 145 insertions(+), 91 deletions(-) delete mode 100644 deploy/clickhouse-client-config.example.xml create mode 100644 deploy/sql-browser.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 984e74d..6e22f21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,11 +46,13 @@ jobs: set -euo pipefail tmp=$(mktemp -d) tar -C "$tmp" -xzf dist/altinity-sql-browser.tar.gz --strip-components=1 - for f in sql.html local.py config.example.xml run.sh VERSION; do + for f in sql.html local.py sql-browser.xml run.sh VERSION; do test -f "$tmp/$f" || { echo "missing $f in bundle" >&2; exit 1; } done python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$tmp/local.py" - LOCAL_CH_CONFIG="$tmp/config.example.xml" PORT=8901 "$tmp/run.sh" & + # No LOCAL_CH_CONFIG: the runner discovers the bundled sql-browser.xml + # next to local.py, proving the merge/discovery path end to end. + PORT=8901 "$tmp/run.sh" & pid=$! for i in $(seq 1 20); do curl -fsS "http://localhost:8901/sql" >/dev/null 2>&1 && break; sleep 0.5; done curl -fsS "http://localhost:8901/sql" >/dev/null diff --git a/README.md b/README.md index 047e2fe..927e8cb 100644 --- a/README.md +++ b/README.md @@ -180,10 +180,14 @@ altinity-sql-browser # serve → open http://localhost:8900/sql This downloads the latest [release](https://github.com/Altinity/altinity-sql-browser/releases) bundle (the prebuilt single-file SPA + the zero-dependency Python runner) into `~/.altinity-sql-browser` and installs a launcher in `~/.local/bin`. Overrides: -`ASB_VERSION` (tag to install), `ASB_HOME`, `ASB_BIN`. The bundle ships a sample -**`config.example.xml`** (a clickhouse-client connections template — it does *not* -touch your real `~/.clickhouse-client/config.xml`); try it with -`LOCAL_CH_CONFIG=~/.altinity-sql-browser/config.example.xml altinity-sql-browser`. +`ASB_VERSION` (tag to install), `ASB_HOME`, `ASB_BIN`. + +The installer also writes a sample **`~/.clickhouse-client/sql-browser.xml`** (a few +public demo clusters) — under a separate name, so it **never replaces your real +`config.xml`**. The runner **merges** connections from both files (your `config.xml` +wins on a name clash), so a fresh machine has something to connect to immediately. +The picker reads `` if set, else defaults to ClickHouse's HTTP interface +(`8443` secure / `8123` plain) — the native `` (9440/9000) is not used. **From a checkout** (also builds the SPA, needs Node): diff --git a/build/bundle.sh b/build/bundle.sh index 52a9c19..537ae1e 100755 --- a/build/bundle.sh +++ b/build/bundle.sh @@ -6,7 +6,8 @@ # altinity-sql-browser/ # sql.html — the prebuilt single-file SPA # local.py — the zero-dep Python runner (serves SPA + config.json) -# config.example.xml — sample clickhouse-client connections (template) +# sql-browser.xml — sample public-demo connections (merged with the +# user's ~/.clickhouse-client/config.xml by the runner) # run.sh — self-resolving launcher (python3 local.py) # VERSION # README.txt @@ -26,9 +27,9 @@ node "$ROOT/build/build.mjs" echo "==> Staging bundle ($VERSION)" rm -rf "$OUT/bundle" mkdir -p "$STAGE" -cp "$OUT/sql.html" "$STAGE/sql.html" -cp "$ROOT/build/local.py" "$STAGE/local.py" -cp "$ROOT/deploy/clickhouse-client-config.example.xml" "$STAGE/config.example.xml" +cp "$OUT/sql.html" "$STAGE/sql.html" +cp "$ROOT/build/local.py" "$STAGE/local.py" +cp "$ROOT/deploy/sql-browser.xml" "$STAGE/sql-browser.xml" printf '%s\n' "$VERSION" > "$STAGE/VERSION" cat > "$STAGE/run.sh" <<'EOF' @@ -49,12 +50,13 @@ Requires only python3 (preinstalled on macOS/Linux). ./run.sh # serve http://localhost:8900/sql -It reads your ~/.clickhouse-client/config.xml and offers each connection in the -login picker. To try the bundled sample instead (your real config is untouched): +It merges connections from your ~/.clickhouse-client/config.xml and the bundled +sql-browser.xml (public demos) and offers each in the login picker — your own +config wins on a name clash. Run ./install.sh (curl|sh) to also copy +sql-browser.xml into ~/.clickhouse-client/. - LOCAL_CH_CONFIG=./config.example.xml ./run.sh - -Env: PORT (default 8900), LOCAL_CH_CONFIG, SQL_BROWSER_SPA. +Env: PORT (default 8900), LOCAL_CH_CONFIG (override with one explicit file), +SQL_BROWSER_SPA. Source & docs: https://github.com/Altinity/altinity-sql-browser EOF diff --git a/build/local.py b/build/local.py index 13edb08..01f6ff3 100644 --- a/build/local.py +++ b/build/local.py @@ -6,7 +6,9 @@ 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 +It merges connections from `~/.clickhouse-client/config.xml` (your own) and +`~/.clickhouse-client/sql-browser.xml` (the demo file installed by install.sh), +de-duped by name (your config.xml wins), 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`, @@ -23,7 +25,7 @@ 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) +Env: PORT (default 8900) · LOCAL_CH_CONFIG (override with a single explicit file) · SQL_BROWSER_SPA (override the sql.html path). """ import json @@ -52,7 +54,34 @@ def _find_spa(): SPA = _find_spa() PORT = int(os.environ.get("PORT", "8900")) -CH_CONFIG = os.environ.get("LOCAL_CH_CONFIG") or os.path.expanduser("~/.clickhouse-client/config.xml") +CH_DIR = os.path.expanduser("~/.clickhouse-client") + + +def config_paths(): + """Files to read connections from, in precedence order (first wins on a name + clash). LOCAL_CH_CONFIG overrides the list with a single explicit file; else + we merge the user's own `config.xml` with the SQL-browser demo file — + installed at ~/.clickhouse-client/sql-browser.xml and/or shipped next to this + runner in the release bundle. Missing files are skipped silently.""" + env = os.environ.get("LOCAL_CH_CONFIG") + if env: + return [env] + return [ + os.path.join(CH_DIR, "config.xml"), # the user's own connections — win on clash + os.path.join(CH_DIR, "sql-browser.xml"), # installed by install.sh + os.path.join(HERE, "sql-browser.xml"), # run-from-bundle fallback (same names → deduped) + ] + + +def _connections(): + """Yield elements from every configured file that parses.""" + for path in config_paths(): + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError): + continue + for conn in root.iter("connection"): + yield conn def _text(conn, *names): @@ -65,23 +94,25 @@ def _text(conn, *names): 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 []): + """Generate config.json from the clickhouse-client connections (best-effort), + merged across config_paths() and de-duped by connection name (first file wins).""" + idps, hosts, seen, names = [], [], set(), set() + for conn in _connections(): name = _text(conn, "name") hostname = _text(conn, "hostname") - if not name or not hostname: + if not name or not hostname or name in names: continue + names.add(name) secure = _text(conn, "secure").lower() in ("1", "true", "yes") # A self-signed / wrong-host TLS cert. The browser can't bypass cert # validation from fetch(), so the SPA can't honour this on its own — it # flags the connection and walks the user through trusting the cert once # (see populateHosts in src/ui/login.js). insecure = _text(conn, "accept-invalid-certificate", "accept_invalid_certificate").lower() in ("1", "true", "yes") + # The browser talks to ClickHouse's HTTP interface, NOT the native + # (9440/9000) clickhouse-client uses — those are independent server ports, + # so we never derive one from the other. Read if given; else + # fall back to the HTTP-interface default keyed off `secure`. http_port = _text(conn, "http_port", "http-port") scheme = "https" if secure else "http" # Default to ClickHouse's HTTP-interface ports (8443 TLS / 8123 plain), NOT @@ -154,10 +185,11 @@ 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"] + srcs = ", ".join(p for p in config_paths() if os.path.exists(p)) or "(no connection files found)" 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" ▸ {len(n)} saved connection(s) from {srcs}\n" f" ▸ Ctrl-C to stop\n" ) try: diff --git a/deploy/clickhouse-client-config.example.xml b/deploy/clickhouse-client-config.example.xml deleted file mode 100644 index b66563a..0000000 --- a/deploy/clickhouse-client-config.example.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - my-cluster - clickhouse.example.com - 1 - default - - - - - - localhost - localhost - 8123 - default - - - - - dev-tenant - support.tenant.dev.altinity.cloud - 1 - admin - 1 - - - - - sso-cluster - clickhouse.example.com - 1 - https://accounts.google.com - YOUR_CLIENT_ID - - - - diff --git a/deploy/sql-browser.xml b/deploy/sql-browser.xml new file mode 100644 index 0000000..d0fdb13 --- /dev/null +++ b/deploy/sql-browser.xml @@ -0,0 +1,66 @@ + + + + + + + + antalya + antalya.demo.altinity.cloud + 1 + 9440 + default + demo + demo + + + + + altinity-demo + github.demo.altinity.cloud + 1 + 9440 + default + demo + demo + + + + + clickhouse-sql + sql-clickhouse.clickhouse.com + 1 + 9440 + default + play + + + + + + + diff --git a/install.sh b/install.sh index 8ad69f5..801deae 100755 --- a/install.sh +++ b/install.sh @@ -83,6 +83,14 @@ exec python3 "$ASB_HOME/local.py" "\$@" EOF chmod +x "$LAUNCH" +# Install the sample connections beside the user's clickhouse-client config — +# under a separate name so it NEVER replaces their real config.xml. The runner +# merges both. Back up any previous sql-browser.xml first. +CHC="$HOME/.clickhouse-client" +mkdir -p "$CHC" +if [ -e "$CHC/sql-browser.xml" ]; then cp "$CHC/sql-browser.xml" "$CHC/sql-browser.xml.bak"; fi +cp "$ASB_HOME/sql-browser.xml" "$CHC/sql-browser.xml" + say "" say "Installed $(cat "$ASB_HOME/VERSION" 2>/dev/null || echo "$TAG"). Launcher: $LAUNCH" case ":$PATH:" in @@ -90,9 +98,8 @@ case ":$PATH:" in *) say ""; say "NOTE: $ASB_BIN is not on your PATH. Add it:"; say " export PATH=\"$ASB_BIN:\$PATH\"" ;; esac say "" -say "Run it:" -say " altinity-sql-browser # reads ~/.clickhouse-client/config.xml → http://localhost:8900/sql" +say "Sample connections written to $CHC/sql-browser.xml (your config.xml is untouched)." +say "The runner merges both files." say "" -say "No connections configured yet? A sample is bundled (your real config is untouched):" -say " LOCAL_CH_CONFIG=$ASB_HOME/config.example.xml altinity-sql-browser" -say " # or copy it as a starting point: cp $ASB_HOME/config.example.xml ~/.clickhouse-client/config.xml" +say "Run it:" +say " altinity-sql-browser # → http://localhost:8900/sql" From 7789cdf52fd2d46468a4a3044ecce0559141dced Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 14:45:45 +0200 Subject: [PATCH 3/6] feat(local): probe HTTP reachability at startup; skip dead hosts + print table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker only offers connections the browser can actually POST to. At startup the runner resolves each connection's HTTP URL, probes /ping concurrently, prints a status table to stdout, and serves only the reachable hosts — so a native-only endpoint (e.g. play.clickhouse.com, no HTTP interface) is shown as skipped with a reason instead of being a dead pick in the picker. - build/local.py: split build_config into pure collect()/serialize(); add probe() (GET /ping, unverified TLS for accept-invalid hosts; "reachable" = any HTTP response, "unreachable" = connection-level failure) + concurrent probe_all(); main() probes, prints the table, and serializes only kept hosts (dropping an unreachable OAuth host also drops its now-dangling IdP). Flushed so the table shows immediately. SQL_BROWSER_PROBE=0 / SQL_BROWSER_PROBE_TIMEOUT to control it. - ci.yml: smoke test sets SQL_BROWSER_PROBE=0 (don't depend on external hosts). - README + docstring: document the probe + skip behavior. Verified against the real config: 4/5 reachable, clickhouse-play skipped (timed out — native-only); served config.json excludes it. Gate 952 passing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MUc6UU9qs5J3u7qoy5YoNN --- .github/workflows/ci.yml | 3 +- README.md | 5 +- build/local.py | 114 +++++++++++++++++++++++++++++++-------- 3 files changed, 98 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e22f21..365e581 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,8 @@ jobs: python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$tmp/local.py" # No LOCAL_CH_CONFIG: the runner discovers the bundled sql-browser.xml # next to local.py, proving the merge/discovery path end to end. - PORT=8901 "$tmp/run.sh" & + # SQL_BROWSER_PROBE=0: don't depend on reaching external demo hosts from CI. + SQL_BROWSER_PROBE=0 PORT=8901 "$tmp/run.sh" & pid=$! for i in $(seq 1 20); do curl -fsS "http://localhost:8901/sql" >/dev/null 2>&1 && break; sleep 0.5; done curl -fsS "http://localhost:8901/sql" >/dev/null diff --git a/README.md b/README.md index 927e8cb..1149241 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,10 @@ public demo clusters) — under a separate name, so it **never replaces your rea `config.xml`**. The runner **merges** connections from both files (your `config.xml` wins on a name clash), so a fresh machine has something to connect to immediately. The picker reads `` if set, else defaults to ClickHouse's HTTP interface -(`8443` secure / `8123` plain) — the native `` (9440/9000) is not used. +(`8443` secure / `8123` plain) — the native `` (9440/9000) is not used. At +startup the runner **probes each host's HTTP interface and prints a reachability +table**, skipping any with no HTTP interface (e.g. a native-only endpoint) so they +aren't dead picks. Set `SQL_BROWSER_PROBE=0` to keep all hosts. **From a checkout** (also builds the SPA, needs Node): diff --git a/build/local.py b/build/local.py index 01f6ff3..6fc4f2f 100644 --- a/build/local.py +++ b/build/local.py @@ -25,13 +25,22 @@ 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). +At startup it probes each connection's HTTP interface and prints a reachability +table; hosts with no HTTP interface (native-only endpoints) are skipped from the +picker so they aren't dead picks. Set SQL_BROWSER_PROBE=0 to keep all hosts. + Env: PORT (default 8900) · LOCAL_CH_CONFIG (override with a single explicit file) - · SQL_BROWSER_SPA (override the sql.html path). + · SQL_BROWSER_SPA (override the sql.html path) · SQL_BROWSER_PROBE (0 to skip + the reachability probe) · SQL_BROWSER_PROBE_TIMEOUT (seconds, default 4). """ import json import os +import ssl import sys +import urllib.error +import urllib.request import xml.etree.ElementTree as ET +from concurrent.futures import ThreadPoolExecutor from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HERE = os.path.dirname(os.path.abspath(__file__)) @@ -93,10 +102,10 @@ def _text(conn, *names): return "" -def build_config(): - """Generate config.json from the clickhouse-client connections (best-effort), - merged across config_paths() and de-duped by connection name (first file wins).""" - idps, hosts, seen, names = [], [], set(), set() +def collect(): + """Parse + merge + de-dupe the connection files (first file wins on a name + clash). Pure — no network. Returns (idps_by_id, hosts).""" + idps_by_id, hosts, names = {}, [], set() for conn in _connections(): name = _text(conn, "name") hostname = _text(conn, "hostname") @@ -131,24 +140,64 @@ def build_config(): 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) + idps_by_id.setdefault(name, { + "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", + }) hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name, "insecure": insecure}) else: hosts.append({"label": name, "url": url, "auth": "basic", "user": _text(conn, "user"), "password": _text(conn, "password"), "insecure": insecure}) + return idps_by_id, hosts + + +def serialize(idps_by_id, hosts): + """Render config.json bytes, keeping only IdPs still referenced by a kept host + (so dropping an unreachable OAuth host doesn't leave a dangling SSO button).""" + used = {h["idp"] for h in hosts if h.get("auth") == "oauth"} + idps = [idps_by_id[i] for i in idps_by_id if i in used] return json.dumps({"basic_login": True, "idps": idps, "hosts": hosts}).encode() +PROBE_TIMEOUT = float(os.environ.get("SQL_BROWSER_PROBE_TIMEOUT", "4")) + + +def probe(host): + """Best-effort reachability check of a host's HTTP interface (GET /ping). + Returns (reachable, detail). 'Reachable' = the port answered HTTP at all + (any status — auth/redirect is the user's concern); 'unreachable' = a + connection-level failure (refused, timeout, TLS, DNS) → no HTTP interface to + POST queries to (e.g. a native-only endpoint like play.clickhouse.com).""" + ctx = ssl._create_unverified_context() if host.get("insecure") else None + try: + req = urllib.request.Request(host["url"].rstrip("/") + "/ping", method="GET") + with urllib.request.urlopen(req, timeout=PROBE_TIMEOUT, context=ctx) as r: + return True, f"HTTP {r.status}" + except urllib.error.HTTPError as e: + return True, f"HTTP {e.code}" # answered → has an HTTP interface + except urllib.error.URLError as e: # refused / timeout / TLS / DNS + return False, str(getattr(e, "reason", "") or type(e).__name__) + except Exception as e: + return False, type(e).__name__ + + +def probe_all(hosts): + """Probe every host concurrently. Returns a list of (reachable, detail) aligned + with `hosts`.""" + with ThreadPoolExecutor(max_workers=min(8, max(1, len(hosts)))) as ex: + return list(ex.map(probe, hosts)) + + +def build_config(): + """All connections as config.json bytes (no probing) — used by tests/imports.""" + return serialize(*collect()) + + CONFIG = build_config() @@ -184,14 +233,35 @@ def log_message(self, *_a): # keep the console quiet 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"] + global CONFIG + idps_by_id, hosts = collect() srcs = ", ".join(p for p in config_paths() if os.path.exists(p)) or "(no connection files found)" - print( - f"\n Altinity SQL Browser - local static server\n" - f" ▸ open http://localhost:{PORT}/sql\n" - f" ▸ {len(n)} saved connection(s) from {srcs}\n" - f" ▸ Ctrl-C to stop\n" - ) + probe_on = os.environ.get("SQL_BROWSER_PROBE", "1") != "0" and bool(hosts) + + # Probe each host's HTTP interface so the picker only offers ones the browser + # can actually POST to (a native-only endpoint like play.clickhouse.com is + # otherwise a dead pick). The full situation is printed so nothing is silently + # dropped. Disable with SQL_BROWSER_PROBE=0. + results = probe_all(hosts) if probe_on else [(True, "not probed")] * len(hosts) + kept = [h for h, (ok, _d) in zip(hosts, results) if ok] + CONFIG = serialize(idps_by_id, kept) + + width = max((len(h["label"]) for h in hosts), default=0) + lines = ["", + " Altinity SQL Browser - local static server", + f" ▸ open http://localhost:{PORT}/sql", + f" ▸ connections from {srcs}:" if hosts else " ▸ no connections found"] + for h, (ok, detail) in zip(hosts, results): + mark = "✓" if ok else "✗" + note = "" if ok else f" — unreachable ({detail}); skipped" + lines.append(f" {mark} {h['label']:<{width}} {h['auth']:<5} {h['url']}{note}") + if probe_on: + reachable = sum(1 for ok, _ in results if ok) + extra = f", {len(hosts) - reachable} skipped" if reachable < len(hosts) else "" + lines.append(f" ▸ {reachable}/{len(hosts)} reachable{extra}") + lines += [" ▸ Ctrl-C to stop (SQL_BROWSER_PROBE=0 to skip the reachability check)", ""] + print("\n".join(lines), flush=True) # flush: serve_forever() never returns to flush for us + try: ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() except KeyboardInterrupt: From 09f273386c33fea6671fcf689d2d977f9a006948 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 14:55:01 +0200 Subject: [PATCH 4/6] feat(local): probe both HTTP ports (8443/443, 8123/80) before skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port discovery is core to the runner: a cluster may serve the HTTP interface on 8443 (direct) or 443 (TLS-terminating proxy), and the config only carries the native . So when no is given, try both standard ports and pin whichever answers Ok. on /ping; only skip a host when NO port has an HTTP interface. - collect(): build a per-host candidate list (_alts) — [8443,443] secure / [8123,80] plain, or the single explicit/embedded port; default url = first. - probe(): try candidates in order, first /ping == "Ok." wins; returns the chosen URL (port pinned) or None. serialize() strips the internal _alts field. - main(): table shows the winning port per host, or the per-port failure reasons for a skip. Effect: clickhouse-play, which times out on :8443, now resolves on :443 (its /ping returns Ok.) → 5/5 reachable instead of being skipped. Gate 952 passing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MUc6UU9qs5J3u7qoy5YoNN --- README.md | 12 +++--- build/local.py | 113 +++++++++++++++++++++++++++++++------------------ 2 files changed, 78 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 1149241..ad24f33 100644 --- a/README.md +++ b/README.md @@ -186,11 +186,13 @@ The installer also writes a sample **`~/.clickhouse-client/sql-browser.xml`** (a public demo clusters) — under a separate name, so it **never replaces your real `config.xml`**. The runner **merges** connections from both files (your `config.xml` wins on a name clash), so a fresh machine has something to connect to immediately. -The picker reads `` if set, else defaults to ClickHouse's HTTP interface -(`8443` secure / `8123` plain) — the native `` (9440/9000) is not used. At -startup the runner **probes each host's HTTP interface and prints a reachability -table**, skipping any with no HTTP interface (e.g. a native-only endpoint) so they -aren't dead picks. Set `SQL_BROWSER_PROBE=0` to keep all hosts. +The picker uses `` if set; otherwise, since a cluster may serve the +HTTP interface on either port, at startup the runner **probes both standard ports** +(`8443` then `443` for secure, `8123` then `80` for plain) and uses whichever +answers `Ok.` on `/ping`. The native `` (9440/9000) is never used — it's a +different interface. The probe **prints a reachability table** and skips any host +with no HTTP interface on any port (e.g. a native-only endpoint) so it isn't a dead +pick. Set `SQL_BROWSER_PROBE=0` to skip probing and keep all hosts (`8443`/`8123`). **From a checkout** (also builds the SPA, needs Node): diff --git a/build/local.py b/build/local.py index 6fc4f2f..1fdbe4b 100644 --- a/build/local.py +++ b/build/local.py @@ -25,9 +25,11 @@ 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). -At startup it probes each connection's HTTP interface and prints a reachability -table; hosts with no HTTP interface (native-only endpoints) are skipped from the -picker so they aren't dead picks. Set SQL_BROWSER_PROBE=0 to keep all hosts. +At startup it probes each connection's HTTP interface — trying both standard ports +(8443/443 or 8123/80) and using whichever answers Ok. on /ping — and prints a +reachability table; hosts with no HTTP interface on any port (native-only +endpoints) are skipped from the picker so they aren't dead picks. Set +SQL_BROWSER_PROBE=0 to keep all hosts. Env: PORT (default 8900) · LOCAL_CH_CONFIG (override with a single explicit file) · SQL_BROWSER_SPA (override the sql.html path) · SQL_BROWSER_PROBE (0 to skip @@ -124,17 +126,20 @@ def collect(): # fall back to the HTTP-interface default keyed off `secure`. http_port = _text(conn, "http_port", "http-port") scheme = "https" if secure else "http" - # Default to ClickHouse's HTTP-interface ports (8443 TLS / 8123 plain), NOT - # 443/80 — mirrors the SPA's resolveTarget for a bare host. Managed endpoints - # often park an auth gateway on 443 (a browser GET 302s to an SSO login), so - # 443 wouldn't reach ClickHouse; 8443 is the direct HTTPS interface. Set an - # explicit to override (e.g. 443 for a proxy that fronts the HTTP - # interface there with no gateway). - # Don't double-append when already carries a port (clickhouse-client - # accepts host:port), else 'h:9000' would become 'h:9000:8443'. + # Candidate HTTP URLs to try, in order — the startup probe picks the first + # whose /ping answers (see probe()). An explicit or a port + # already embedded in is respected as the sole candidate; + # otherwise we try BOTH standard HTTP ports for the scheme, since a cluster + # may serve ClickHouse on 8443 (direct) or 443 (TLS-terminating proxy). The + # native (9440/9000) is never used — it's a different interface. tail = hostname.rsplit(":", 1) - has_port = len(tail) == 2 and tail[1].isdigit() - url = f"{scheme}://{hostname}" if has_port else f"{scheme}://{hostname}:{http_port or ('8443' if secure else '8123')}" + if len(tail) == 2 and tail[1].isdigit(): # hostname already has a port + alts = [f"{scheme}://{hostname}"] + elif http_port: # explicit override + alts = [f"{scheme}://{hostname}:{http_port}"] + else: # try both standard ports + alts = [f"{scheme}://{hostname}:{p}" for p in (("8443", "443") if secure else ("8123", "80"))] + url = alts[0] 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") @@ -148,47 +153,63 @@ def collect(): "client_secret": oauth_secret, "audience": oauth_aud, "bearer": "access_token" if oauth_aud else "id_token", }) - hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name, "insecure": insecure}) + hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name, + "insecure": insecure, "_alts": alts}) else: hosts.append({"label": name, "url": url, "auth": "basic", "user": _text(conn, "user"), "password": _text(conn, "password"), - "insecure": insecure}) + "insecure": insecure, "_alts": alts}) return idps_by_id, hosts def serialize(idps_by_id, hosts): """Render config.json bytes, keeping only IdPs still referenced by a kept host - (so dropping an unreachable OAuth host doesn't leave a dangling SSO button).""" + (so dropping an unreachable OAuth host doesn't leave a dangling SSO button) and + stripping internal `_`-prefixed fields (e.g. `_alts`) from the hosts.""" used = {h["idp"] for h in hosts if h.get("auth") == "oauth"} idps = [idps_by_id[i] for i in idps_by_id if i in used] - return json.dumps({"basic_login": True, "idps": idps, "hosts": hosts}).encode() + clean = [{k: v for k, v in h.items() if not k.startswith("_")} for h in hosts] + return json.dumps({"basic_login": True, "idps": idps, "hosts": clean}).encode() PROBE_TIMEOUT = float(os.environ.get("SQL_BROWSER_PROBE_TIMEOUT", "4")) -def probe(host): - """Best-effort reachability check of a host's HTTP interface (GET /ping). - Returns (reachable, detail). 'Reachable' = the port answered HTTP at all - (any status — auth/redirect is the user's concern); 'unreachable' = a - connection-level failure (refused, timeout, TLS, DNS) → no HTTP interface to - POST queries to (e.g. a native-only endpoint like play.clickhouse.com).""" - ctx = ssl._create_unverified_context() if host.get("insecure") else None +def _ping_ok(url, insecure): + """GET /ping and report whether ClickHouse answered 'Ok.'. Returns + (ok, detail).""" + ctx = ssl._create_unverified_context() if insecure else None try: - req = urllib.request.Request(host["url"].rstrip("/") + "/ping", method="GET") + req = urllib.request.Request(url.rstrip("/") + "/ping", method="GET") with urllib.request.urlopen(req, timeout=PROBE_TIMEOUT, context=ctx) as r: - return True, f"HTTP {r.status}" + body = r.read(16).decode("ascii", "replace").strip() + return body == "Ok.", (f"HTTP {r.status}" if body == "Ok." else f"HTTP {r.status}, not ClickHouse") except urllib.error.HTTPError as e: - return True, f"HTTP {e.code}" # answered → has an HTTP interface + return False, f"HTTP {e.code}" except urllib.error.URLError as e: # refused / timeout / TLS / DNS return False, str(getattr(e, "reason", "") or type(e).__name__) except Exception as e: return False, type(e).__name__ +def probe(host): + """Try the host's candidate ports in order; the first whose /ping answers 'Ok.' + wins. Returns (chosen_url|None, detail). None → no HTTP interface on any port + (e.g. a native-only endpoint like play.clickhouse.com on :8443), so the host is + skipped from the picker rather than offered as a dead pick.""" + notes = [] + for url in host["_alts"]: + ok, detail = _ping_ok(url, host.get("insecure")) + port = url.rsplit(":", 1)[-1] + if ok: + return url, f"port {port}" + notes.append(f"{port}: {detail}") + return None, "; ".join(notes) + + def probe_all(hosts): - """Probe every host concurrently. Returns a list of (reachable, detail) aligned - with `hosts`.""" + """Probe every host concurrently. Returns a list of (chosen_url|None, detail) + aligned with `hosts`.""" with ThreadPoolExecutor(max_workers=min(8, max(1, len(hosts)))) as ex: return list(ex.map(probe, hosts)) @@ -238,12 +259,19 @@ def main(): srcs = ", ".join(p for p in config_paths() if os.path.exists(p)) or "(no connection files found)" probe_on = os.environ.get("SQL_BROWSER_PROBE", "1") != "0" and bool(hosts) - # Probe each host's HTTP interface so the picker only offers ones the browser - # can actually POST to (a native-only endpoint like play.clickhouse.com is - # otherwise a dead pick). The full situation is printed so nothing is silently - # dropped. Disable with SQL_BROWSER_PROBE=0. - results = probe_all(hosts) if probe_on else [(True, "not probed")] * len(hosts) - kept = [h for h, (ok, _d) in zip(hosts, results) if ok] + # Probe each host's HTTP interface (trying both candidate ports) so the picker + # only offers ones the browser can actually reach — a native-only endpoint like + # play.clickhouse.com on :8443 is otherwise a dead pick. The full situation is + # printed so nothing is silently dropped. Disable with SQL_BROWSER_PROBE=0. + if probe_on: + results = probe_all(hosts) + else: + results = [(h["_alts"][0], "not probed") for h in hosts] + kept = [] + for h, (chosen, _d) in zip(hosts, results): + if chosen: + h["url"] = chosen # pin the port that actually answered + kept.append(h) CONFIG = serialize(idps_by_id, kept) width = max((len(h["label"]) for h in hosts), default=0) @@ -251,14 +279,15 @@ def main(): " Altinity SQL Browser - local static server", f" ▸ open http://localhost:{PORT}/sql", f" ▸ connections from {srcs}:" if hosts else " ▸ no connections found"] - for h, (ok, detail) in zip(hosts, results): - mark = "✓" if ok else "✗" - note = "" if ok else f" — unreachable ({detail}); skipped" - lines.append(f" {mark} {h['label']:<{width}} {h['auth']:<5} {h['url']}{note}") + for h, (chosen, detail) in zip(hosts, results): + if chosen: + lines.append(f" ✓ {h['label']:<{width}} {h['auth']:<5} {chosen} ({detail})") + else: + lines.append(f" ✗ {h['label']:<{width}} {h['auth']:<5} no HTTP interface — skipped [{detail}]") if probe_on: - reachable = sum(1 for ok, _ in results if ok) - extra = f", {len(hosts) - reachable} skipped" if reachable < len(hosts) else "" - lines.append(f" ▸ {reachable}/{len(hosts)} reachable{extra}") + n = sum(1 for c, _ in results if c) + extra = f", {len(hosts) - n} skipped" if n < len(hosts) else "" + lines.append(f" ▸ {n}/{len(hosts)} reachable{extra}") lines += [" ▸ Ctrl-C to stop (SQL_BROWSER_PROBE=0 to skip the reachability check)", ""] print("\n".join(lines), flush=True) # flush: serve_forever() never returns to flush for us From 51e3844a5c52fd068c2746c082fc9c52110751e5 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 15:03:48 +0200 Subject: [PATCH 5/6] fix(login): allow passwordless connect (username only) for demo clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Connect button required BOTH a username and a non-empty password, so a passwordless user (e.g. ClickHouse `play` on the public playground / our bundled demos) could never connect — the button stayed disabled. Require only a username; an empty password sends HTTP Basic `user:`, which ClickHouse accepts. - src/ui/login.js: hasCreds() now checks the username only. - tests: a username alone enables Connect (primary, "as "); Enter submits with a username and no password; selecting a passwordless saved connection enables Connect. Updated the prior "needs both fields" Enter test. Verified in-browser against the public playground: selecting the passwordless demo enables Connect, login succeeds, and the schema loads. Gate 954 passing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MUc6UU9qs5J3u7qoy5YoNN --- src/ui/login.js | 5 ++++- tests/unit/login.test.js | 33 +++++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/ui/login.js b/src/ui/login.js index 928a7e2..032dd3b 100644 --- a/src/ui/login.js +++ b/src/ui/login.js @@ -33,7 +33,10 @@ export function renderLogin(app, errorMsg) { let advOpen = !!hostHint; let ssoBtns = []; - const hasCreds = () => userInput.value.trim().length > 0 && passInput.value.length > 0; + // A username is enough to connect — the password is optional, since passwordless + // users are common on demo/playground clusters (e.g. ClickHouse `play`). An empty + // password sends HTTP Basic `user:` which ClickHouse accepts. + const hasCreds = () => userInput.value.trim().length > 0; // --- credential fields --- const fld = (over) => h('input', { diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js index f779cf1..d627dbc 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -72,6 +72,16 @@ describe('renderLogin — host picker', () => { expect([host.value, user.value, pass.value]).toEqual(['http://localhost:8123', 'default', 'pw']); expect(app.root.querySelector('.login-adv-field').style.display).toBe(''); }); + it('selecting a passwordless basic host (empty password) still enables Connect', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts: [ + { label: 'clickhouse-sql', url: 'https://sql-clickhouse.clickhouse.com:8443', auth: 'basic', user: 'play', password: '', idp: '' }, + ] }) }); + renderLogin(app); await tick(); + selectHost(app.root, '0'); + const [user, pass] = app.root.querySelectorAll('.login-input'); + expect([user.value, pass.value]).toEqual(['play', '']); + expect(app.root.querySelector('.login-creds .login-btn').disabled).toBe(false); + }); it('selecting an OAuth host starts SSO against that cluster', async () => { const login = vi.fn(async () => {}); const app = withHosts({ actions: { login } }); @@ -281,6 +291,18 @@ describe('renderLogin — credentials reactivity', () => { expect(sso.classList.contains('btn-ghost')).toBe(true); expect(app.root.querySelector('.lt-as').textContent).toBe('as default'); }); + it('a username alone enables Connect — password is optional (passwordless demos like `play`)', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) }); + renderLogin(app); + await tick(); + const [user] = app.root.querySelectorAll('.login-input'); + const connect = app.root.querySelector('.login-creds .login-btn'); + expect(connect.disabled).toBe(true); // nothing typed yet + type(user, 'play'); // username only, no password + expect(connect.disabled).toBe(false); + expect(connect.classList.contains('btn-primary')).toBe(true); + expect(app.root.querySelector('.lt-as').textContent).toBe('as play'); + }); it('the host field drives the target host', () => { const app = appWith(); renderLogin(app); @@ -336,16 +358,19 @@ describe('renderLogin — connect flow', () => { await tick(); expect(connect).toHaveBeenCalled(); }); - it('Enter without both credentials does nothing; other keys ignored', async () => { + it('Enter is a no-op with no username and for non-Enter keys; submits once a username is present', async () => { const connect = vi.fn(async () => {}); const app = appWith({ actions: { connect } }); renderLogin(app); const [user] = app.root.querySelectorAll('.login-input'); - type(user, 'only-user'); - user.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - user.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + user.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); // empty → no-op + type(user, 'play'); + user.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); // non-Enter → ignored await tick(); expect(connect).not.toHaveBeenCalled(); + user.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); // username present → submits (no password) + await tick(); + expect(connect).toHaveBeenCalledWith({ username: 'play', password: '', host: '' }); }); it('clicking Connect with empty fields is a no-op', async () => { const connect = vi.fn(async () => {}); From 361fc7d260bacf1f5da4a5e49f1f6d5a8fed9ede Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 15:12:46 +0200 Subject: [PATCH 6/6] fix(app): header shows the picked cluster; probe 443 before 8443 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for cross-origin picker connections: - src/ui/app.js: app.host() returned the serving host (loc.host) in OAuth mode, so signing in via a picker connection that targets another cluster showed "localhost:8900" instead of the cluster. Use chCtx.origin's host for both auth modes — it already resolves to the basic target / oauth_origin / serving origin. (URL.host drops a default :443, so a 443 cluster shows a bare hostname.) - build/local.py: probe 443 before 8443 for secure connections (8123 then 80 for plain). 443 is the canonical public endpoint for managed clusters and avoids the 8443 timeout when only 443 is open; the /ping=='Ok.' check still rejects a 443 auth-gateway and falls through to 8443 (e.g. the support tenant). Verified: header shows antalya.demo.altinity.cloud (cross-origin), all demo clusters resolve on :443 (5/5, no 8443 stall). Gate 955 passing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MUc6UU9qs5J3u7qoy5YoNN --- README.md | 2 +- build/local.py | 9 +++++++-- src/ui/app.js | 8 +++++--- tests/unit/app.test.js | 6 ++++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ad24f33..5397512 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ public demo clusters) — under a separate name, so it **never replaces your rea wins on a name clash), so a fresh machine has something to connect to immediately. The picker uses `` if set; otherwise, since a cluster may serve the HTTP interface on either port, at startup the runner **probes both standard ports** -(`8443` then `443` for secure, `8123` then `80` for plain) and uses whichever +(`443` then `8443` for secure, `8123` then `80` for plain) and uses whichever answers `Ok.` on `/ping`. The native `` (9440/9000) is never used — it's a different interface. The probe **prints a reachability table** and skips any host with no HTTP interface on any port (e.g. a native-only endpoint) so it isn't a dead diff --git a/build/local.py b/build/local.py index 1fdbe4b..e13f4d2 100644 --- a/build/local.py +++ b/build/local.py @@ -26,7 +26,8 @@ URI with the IdP and allow CORS from localhost on the cluster (see README). At startup it probes each connection's HTTP interface — trying both standard ports -(8443/443 or 8123/80) and using whichever answers Ok. on /ping — and prints a +(443 then 8443 for secure, 8123 then 80 for plain) and using whichever answers Ok. +on /ping — and prints a reachability table; hosts with no HTTP interface on any port (native-only endpoints) are skipped from the picker so they aren't dead picks. Set SQL_BROWSER_PROBE=0 to keep all hosts. @@ -138,7 +139,11 @@ def collect(): elif http_port: # explicit override alts = [f"{scheme}://{hostname}:{http_port}"] else: # try both standard ports - alts = [f"{scheme}://{hostname}:{p}" for p in (("8443", "443") if secure else ("8123", "80"))] + # Secure: 443 first (the canonical public endpoint for managed clusters, + # and it dodges the 8443 timeout when only 443 is open) — the /ping=='Ok.' + # check still rejects a 443 auth-gateway and falls through to 8443. Plain: + # 8123 first (the ClickHouse default; 80 is rarely the HTTP interface). + alts = [f"{scheme}://{hostname}:{p}" for p in (("443", "8443") if secure else ("8123", "80"))] url = alts[0] oauth_url = _text(conn, "oauth-url", "oauth_url") oauth_client = _text(conn, "oauth-client-id", "oauth_client_id") diff --git a/src/ui/app.js b/src/ui/app.js index c015862..2e95b23 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -99,9 +99,11 @@ export function createApp(env = {}) { app.updateLibraryTitle = () => renderLibraryTitle(app); // --- identity ---------------------------------------------------------- - app.host = () => (app.authMode === 'basic' - ? originHost(chCtx.origin) || 'clickhouse' - : loc.host || 'clickhouse'); + // The host queries actually go to. chCtx.origin already resolves to the basic + // target, the picked OAuth cluster (oauth_origin), or the serving origin — so a + // cross-origin OAuth connection shows the cluster, not localhost. (URL.host drops + // a default :443, so a 443 cluster shows a bare hostname; an 8443 one shows :8443.) + app.host = () => originHost(chCtx.origin) || 'clickhouse'; app.activeTab = () => activeTab(app.state); // A `?host=` query param pre-fills the credential server address on the login // screen (and disables SSO, which only targets the serving host). diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index dd306f8..f306599 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -622,6 +622,12 @@ describe('auth flows', () => { const e = env({ sessionStorage: memSession({ oauth_id_token: validToken, oauth_origin: 'https://antalya.demo.altinity.cloud' }) }); expect(createApp(e).chCtx.origin).toBe('https://antalya.demo.altinity.cloud'); }); + it('header shows the picked cluster, not the serving host, for cross-origin oauth', () => { + // Serving host is ch.example; the picked cluster is antalya on :443 (default + // https port → URL.host drops it, so the header is the bare cluster hostname). + const e = env({ sessionStorage: memSession({ oauth_id_token: validToken, oauth_origin: 'https://antalya.demo.altinity.cloud:443' }) }); + expect(createApp(e).host()).toBe('antalya.demo.altinity.cloud'); + }); }); describe('credentials (basic) sign-in', () => {