From ee190026e0c22c9745df5f3355c948948e2f513c Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Tue, 11 Aug 2026 14:22:48 +0300 Subject: [PATCH 1/2] Deploy the browser analyzer to GitHub Pages Builds web/dist in CI and publishes it with actions/deploy-pages on pushes to main that touch web/ or the analyzer. A gh-pages branch was the alternative and was rejected: it would commit ~13 MB of Pyodide binaries on every rebuild and let the deployed site drift from source, which is the reason build.sh fetches the runtime at build time in the first place. The workflow serves the built site and runs bench/check_page.py against it, so the deploy fails if the page reaches an external host, raises a JS error, or renders nothing. That check was previously decorative: it watched responses, and an external reference that fails to resolve never produces one, so a blocked request would have passed silently. Verified both directions by injecting an external image into a built copy -- caught now, missed before. Both READMEs lead with the hosted link. It needs no install and no upload, which is the shortest honest path from reading about the tool to seeing it work. --- .github/workflows/pages.yml | 79 +++++++++++++++++++++++++++++++++++++ README.md | 7 +++- README.ru.md | 8 +++- web/README.md | 13 ++++-- web/bench/check_page.py | 43 +++++++++++++------- 5 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..25d99a0 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,79 @@ +name: Pages + +on: + push: + branches: + - main + paths: + - "web/**" + - "query_doctor/analyzer/**" + - "query_doctor/report/**" + - "query_doctor/safety/**" + - "query_doctor/impala/**" + - "pyproject.toml" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build the browser analyzer + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Install build and check tooling + run: | + python -m pip install --upgrade pip + python -m pip install build playwright + + - name: Install Chromium browser + run: python -m playwright install --with-deps chromium + + - name: Build the site + run: web/build.sh + + - name: Verify the built site loads and reaches no external host + run: | + python -m http.server 8799 --directory web/dist & + server_pid=$! + trap 'kill "${server_pid}"' EXIT + python web/bench/check_page.py "${RUNNER_TEMP}/page.png" + + - name: Upload the Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: web/dist + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 75f05c9..59ca3eb 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,12 @@ Python owns facts. LLM owns wording only. ## Try It -No cluster, no config, no credentials — synthetic data only: +**[Analyze a profile in your browser](https://alexandrefimov.github.io/Query-Doctor/)** +— drop an exported Impala text profile and get the diagnosis. Nothing installs +and nothing uploads: the analyzer runs in your browser through WebAssembly, and +the page makes no request to any host after it loads. + +Locally, with no cluster, config, or credentials — synthetic data only: ```bash python -m pip install query-doctor diff --git a/README.ru.md b/README.ru.md index dfcea71..51f5cd2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -23,7 +23,13 @@ Python owns facts. LLM owns wording only. ## Попробовать -Без кластера, конфигурации и доступов — только синтетические данные: +**[Разобрать профиль прямо в браузере](https://alexandrefimov.github.io/Query-Doctor/)** +— перетащите экспортированный текстовый профиль Impala и получите разбор. +Ставить ничего не нужно, и никуда ничего не уходит: анализатор выполняется +у вас в браузере через WebAssembly, а страница после загрузки не делает ни +одного запроса. + +Локально, без кластера, конфигурации и доступов — только синтетические данные: ```bash python -m pip install query-doctor diff --git a/web/README.md b/web/README.md index 693715b..0ccc838 100644 --- a/web/README.md +++ b/web/README.md @@ -10,7 +10,11 @@ does not: there is no server, no upload, and no request to any host after the page loads. That is checkable in DevTools in ten seconds, which the safety documentation is not. -Status: working prototype on a branch. Not deployed, not linked from the README. +Deployed to https://alexandrefimov.github.io/Query-Doctor/ by +`.github/workflows/pages.yml` on pushes to `main` that touch `web/` or the +analyzer, and linked from the README. The workflow builds the site, serves it, +and runs `bench/check_page.py` against it; that check fails the build if the +page reaches any external host, raises a JS error, or produces no output. ## Build and run @@ -69,7 +73,7 @@ Full page in headless Chromium (`bench/check_page.py`): ``` boot: 1196 ms (runtime + wheel + analyzer import) run: 127 ms (103 KiB sample profile) -network: 8 requests, external hosts: none +network: 8 requests, external hosts: none (the check fails on either) JS errors: none ``` @@ -97,8 +101,9 @@ profile dialects in `analyzer/profile_counter_registry.py`. ## Open decisions -- **Hosting.** Not deployed. A `gh-pages` branch or an `actions/deploy-pages` - workflow from `web/dist` both work; neither is set up. +- ~~**Hosting.**~~ Settled: `actions/deploy-pages` from `web/dist`. A `gh-pages` + branch was rejected because it would commit ~13 MB of Pyodide binaries per + rebuild and let the deployed site drift from source. - **Output language.** Settled: English. Page chrome and `render_md` output now match. A Russian layer would follow the `docs/i18n/ru/` convention, not a second page. diff --git a/web/bench/check_page.py b/web/bench/check_page.py index 03ba3e8..b9c7df3 100644 --- a/web/bench/check_page.py +++ b/web/bench/check_page.py @@ -8,7 +8,7 @@ URL = "http://127.0.0.1:8799/" OUT = sys.argv[1] if len(sys.argv) > 1 else "page.png" -transferred = {"bytes": 0, "requests": 0, "external": []} +transferred = {"requests": 0, "external": []} def main() -> int: @@ -19,18 +19,24 @@ def main() -> int: page.on("pageerror", lambda e: errors.append(str(e))) page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None) - def on_response(resp): + # Track requests, not responses: an external reference that fails to + # resolve never produces a response, which is exactly the case a gate + # for "reaches no external host" must not miss. + def on_request(request): transferred["requests"] += 1 - if not resp.url.startswith("http://127.0.0.1:8799/"): - transferred["external"].append(resp.url) - try: - transferred["bytes"] += len(resp.body()) - except Exception: - pass + if not request.url.startswith(URL): + transferred["external"].append(request.url) - page.on("response", on_response) + page.on("request", on_request) - page.goto(URL, wait_until="load") + for attempt in range(30): + try: + page.goto(URL, wait_until="load") + break + except Exception: + if attempt == 29: + raise + page.wait_for_timeout(500) page.wait_for_function( "() => document.getElementById('status').textContent.includes('Ready in')", timeout=120_000, @@ -50,16 +56,25 @@ def on_response(resp): print(f"output: {len(headings)} sections, {tables} tables") print("sections:", " | ".join(headings[:12])) - print(f"network: {transferred['requests']} requests, " - f"{transferred['bytes'] / 1048576:.1f} MB total") + print(f"network: {transferred['requests']} requests") print("external hosts:", sorted({u.split('/')[2] for u in transferred['external']}) or "none") page.screenshot(path=OUT, full_page=False) print("screenshot:", OUT) + + failures = [] + if transferred["external"]: + hosts = sorted({u.split("/")[2] for u in transferred["external"]}) + failures.append(f"page reached external hosts: {hosts}") if errors: - print("JS ERRORS:", errors[:5]) + failures.append(f"JS errors: {errors[:5]}") + if not headings: + failures.append("analyzer produced no output sections") + browser.close() - return 1 if errors else 0 + for failure in failures: + print("FAIL:", failure) + return 1 if failures else 0 if __name__ == "__main__": From 162f6d634a06a040cb8d6502fce1e4ee1c41b5ae Mon Sep 17 00:00:00 2001 From: Aleksandr Efimov Date: Tue, 11 Aug 2026 14:29:38 +0300 Subject: [PATCH 2/2] Verify the Pages build on pull requests, deploy only from main --- .github/workflows/pages.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 25d99a0..7d1fca9 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,6 +1,15 @@ name: Pages on: + pull_request: + paths: + - "web/**" + - "query_doctor/analyzer/**" + - "query_doctor/report/**" + - "query_doctor/safety/**" + - "query_doctor/impala/**" + - "pyproject.toml" + - ".github/workflows/pages.yml" push: branches: - main @@ -55,6 +64,7 @@ jobs: python web/bench/check_page.py "${RUNNER_TEMP}/page.png" - name: Upload the Pages artifact + if: github.event_name != 'pull_request' uses: actions/upload-pages-artifact@v3 with: path: web/dist @@ -62,6 +72,7 @@ jobs: deploy: name: Deploy to GitHub Pages needs: build + if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 10