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
90 changes: 90 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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.
Expand Down
43 changes: 29 additions & 14 deletions web/bench/check_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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__":
Expand Down