diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..7d1fca9 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,90 @@ +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 + 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 + if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v3 + with: + path: web/dist + + deploy: + name: Deploy to GitHub Pages + needs: build + if: github.event_name != 'pull_request' + 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__":