diff --git a/.github/workflows/app-adhoc-reporting.yml b/.github/workflows/app-adhoc-reporting.yml new file mode 100644 index 00000000..40bcaa74 --- /dev/null +++ b/.github/workflows/app-adhoc-reporting.yml @@ -0,0 +1,82 @@ +name: app-adhoc-reporting + +# Tests the ad-hoc reporting app in apps/adhoc-reporting. Framework changes (src/) trigger it as +# well, because the contract tests build the app's generated notebooks against the framework +# source of the same commit. Not a required check, so the framework's own CI stays unaffected. +on: + pull_request: + types: [ opened, synchronize, ready_for_review ] + paths: + - 'apps/adhoc-reporting/**' + - 'src/**' + - '.github/workflows/app-adhoc-reporting.yml' + push: + branches: + - main + paths: + - 'apps/adhoc-reporting/**' + - 'src/**' + - '.github/workflows/app-adhoc-reporting.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Same fork gate as acceptance.yml: fork PRs get no OIDC token, so JFrog auth cannot run. + not-a-fork: + runs-on: + group: databrickslabs-protected-runner-group + labels: linux-ubuntu-latest + if: github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork + steps: + - run: echo "Not a fork PR, proceeding" + + test: + name: Test the app + needs: not-a-fork + runs-on: + group: databrickslabs-protected-runner-group + labels: linux-ubuntu-latest + permissions: + id-token: write + defaults: + run: + working-directory: apps/adhoc-reporting + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install UV + uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 + with: + version: "0.11.2" + checksum: "7ac2ca0449c8d68dae9b99e635cd3bc9b22a4cb1de64b7c43716398447d42981" + + # The app runs on Python 3.11 (Databricks Apps), the framework contract tests on 3.12. + - name: Install Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: | + 3.11 + 3.12 + + - name: Install Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + # After the setup steps: the action only configures the package managers it finds. + - name: Setup for JFrog + uses: ./.github/actions/jfrog-auth + + - name: Unit and contract tests (pinned framework release) + run: make test + + - name: Contract tests (framework at this commit) + run: make test FRAMEWORK=../.. + + - name: Build the frontend + run: make frontend diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d67ccd7a..c4f95903 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,12 @@ Notes specific to Impulse: `gold` schemas. You don't need a Databricks workspace to run the unit tests. - Spark warehouse output is written to `spark-warehouse/` (gitignored). +## Apps + +The apps in [`apps/`](apps/) are separate projects with their own dependencies, tests, Makefile +and CI workflow; the root `make` targets don't include them. For developing the Impulse App see +[`apps/adhoc-reporting/README.md`](apps/adhoc-reporting/README.md). + ## Code style We use **black** for formatting and **ruff** for linting. Both are configured in diff --git a/NOTICE.md b/NOTICE.md index 81394d00..588fa83a 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -8,7 +8,7 @@ This Software includes software developed at Databricks (https://www.databricks. Dependencies are grouped below by license. Each table row lists a third-party package bundled or used at build time, along with its license and upstream source. Dev-only dependencies (linters, test runners, build tooling) are marked `dev`; runtime dependencies are marked `runtime`. -This NOTICE covers the full transitive Python graph (`uv.lock` via `pip-licenses`) plus the **direct** docs-site dependencies declared in `docs/impulse/package.json`. +This NOTICE covers the full transitive Python graph (`uv.lock` via `pip-licenses`) plus the **direct** docs-site dependencies declared in `docs/impulse/package.json`. The optional Impulse App in `apps/adhoc-reporting` has its own section, [Impulse App](#impulse-app). --- @@ -187,6 +187,111 @@ Full license text: [https://docs.python.org/3/license.html](https://docs.python. --- +## Impulse App + +The optional Impulse App in `apps/adhoc-reporting` has its own dependencies, grouped below by license in the same way. This section covers its full transitive Python graph (`apps/adhoc-reporting/uv.lock` via `pip-licenses`) plus the **direct** frontend dependencies declared in `apps/adhoc-reporting/frontend/package.json`. + +### Apache License 2.0 + +Full license text: [https://www.apache.org/licenses/LICENSE-2.0](https://www.apache.org/licenses/LICENSE-2.0). + +| Package | Version | Ecosystem | Type | Copyright | Source | +| --- | --- | --- | --- | --- | --- | +| cryptography | 48.0.0 | Python | runtime | The Python Cryptographic Authority and individual contributors | [https://github.com/pyca/cryptography](https://github.com/pyca/cryptography) | +| databricks-sdk | 0.115.0 | Python | runtime | _see upstream_ | [https://databricks-sdk-py.readthedocs.io](https://databricks-sdk-py.readthedocs.io) | +| google-auth | 2.53.0 | Python | runtime | Google Cloud Platform | [https://github.com/googleapis/google-auth-library-python](https://github.com/googleapis/google-auth-library-python) | +| packaging | 26.3 | Python | dev | Donald Stufft | [https://github.com/pypa/packaging](https://github.com/pypa/packaging) | +| requests | 2.34.2 | Python | runtime | Kenneth Reitz | [https://github.com/psf/requests](https://github.com/psf/requests) | +| typescript | 5.9.3 | npm | dev | Microsoft Corp. | [https://github.com/microsoft/TypeScript](https://github.com/microsoft/TypeScript) | + +### BSD 2-Clause License + +Full license text: [https://opensource.org/licenses/BSD-2-Clause](https://opensource.org/licenses/BSD-2-Clause). + +| Package | Version | Ecosystem | Type | Copyright | Source | +| --- | --- | --- | --- | --- | --- | +| pyasn1 | 0.6.3 | Python | runtime | Ilya Etingof | [https://github.com/pyasn1/pyasn1](https://github.com/pyasn1/pyasn1) | +| Pygments | 2.21.0 | Python | dev | Georg Brandl | [https://pygments.org](https://pygments.org) | + +### BSD 3-Clause License + +Full license text: [https://opensource.org/licenses/BSD-3-Clause](https://opensource.org/licenses/BSD-3-Clause). + +| Package | Version | Ecosystem | Type | Copyright | Source | +| --- | --- | --- | --- | --- | --- | +| click | 8.4.1 | Python | runtime | _see upstream_ | [https://github.com/pallets/click/](https://github.com/pallets/click/) | +| httpcore | 1.0.9 | Python | dev | Tom Christie | [https://www.encode.io/httpcore/](https://www.encode.io/httpcore/) | +| idna | 3.17 | Python | runtime | Kim Davies | [https://github.com/kjd/idna](https://github.com/kjd/idna) | +| protobuf | 6.33.6 | Python | runtime | protobuf@googlegroups.com | [https://developers.google.com/protocol-buffers/](https://developers.google.com/protocol-buffers/) | +| pycparser | 3.0 | Python | runtime | Eli Bendersky | [https://github.com/eliben/pycparser](https://github.com/eliben/pycparser) | +| starlette | 1.2.1 | Python | runtime | Tom Christie | [https://github.com/Kludex/starlette](https://github.com/Kludex/starlette) | +| uvicorn | 0.48.0 | Python | runtime | Tom Christie | [https://uvicorn.dev/](https://uvicorn.dev/) | + +### BSD License + +Full license text: [https://opensource.org/licenses/BSD-3-Clause](https://opensource.org/licenses/BSD-3-Clause). + +| Package | Version | Ecosystem | Type | Copyright | Source | +| --- | --- | --- | --- | --- | --- | +| colorama | 0.4.6 | Python | runtime | Jonathan Hartley | [https://github.com/tartley/colorama](https://github.com/tartley/colorama) | +| httpx | 0.28.1 | Python | dev | Tom Christie | [https://github.com/encode/httpx](https://github.com/encode/httpx) | +| pyasn1_modules | 0.4.2 | Python | runtime | Ilya Etingof | [https://github.com/pyasn1/pyasn1-modules](https://github.com/pyasn1/pyasn1-modules) | + +### ISC License + +Full license text: [https://opensource.org/licenses/ISC](https://opensource.org/licenses/ISC). + +| Package | Version | Ecosystem | Type | Copyright | Source | +| --- | --- | --- | --- | --- | --- | +| lucide-react | 1.48.0 | npm | runtime | Eric Fennis | [https://github.com/lucide-icons/lucide](https://github.com/lucide-icons/lucide) | + +### MIT License + +Full license text: [https://opensource.org/licenses/MIT](https://opensource.org/licenses/MIT). + +| Package | Version | Ecosystem | Type | Copyright | Source | +| --- | --- | --- | --- | --- | --- | +| annotated-doc | 0.0.4 | Python | runtime | Sebastián Ramírez | [https://github.com/fastapi/annotated-doc](https://github.com/fastapi/annotated-doc) | +| annotated-types | 0.7.0 | Python | runtime | Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>, Samuel Colvin , Zac Hatfield-Dodds | [https://github.com/annotated-types/annotated-types](https://github.com/annotated-types/annotated-types) | +| anyio | 4.13.0 | Python | runtime | Alex Grönholm | [https://anyio.readthedocs.io/en/stable/versionhistory.html](https://anyio.readthedocs.io/en/stable/versionhistory.html) | +| cffi | 2.0.0 | Python | runtime | Armin Rigo, Maciej Fijalkowski | [https://cffi.readthedocs.io/en/latest/whatsnew.html](https://cffi.readthedocs.io/en/latest/whatsnew.html) | +| charset-normalizer | 3.4.7 | Python | runtime | "Ahmed R. TAHRI" | [https://github.com/jawah/charset_normalizer/blob/master/CHANGELOG.md](https://github.com/jawah/charset_normalizer/blob/master/CHANGELOG.md) | +| fastapi | 0.136.3 | Python | runtime | Sebastián Ramírez | [https://github.com/fastapi/fastapi](https://github.com/fastapi/fastapi) | +| h11 | 0.16.0 | Python | runtime | Nathaniel J. Smith | [https://github.com/python-hyper/h11](https://github.com/python-hyper/h11) | +| iniconfig | 2.3.0 | Python | dev | Ronny Pfannschmidt , Holger Krekel | [https://github.com/pytest-dev/iniconfig](https://github.com/pytest-dev/iniconfig) | +| pluggy | 1.6.0 | Python | dev | Holger Krekel | [https://github.com/pytest-dev/pluggy](https://github.com/pytest-dev/pluggy) | +| pydantic | 2.13.4 | Python | runtime | Samuel Colvin , Eric Jolibois , Hasan Ramezani , Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>, Terrence Dorsey , David Montague , Serge Matveenko , Marcelo Trylesinski , Sydney Runkle , David Hewitt , Alex Hall , Victorien Plot | [https://github.com/pydantic/pydantic](https://github.com/pydantic/pydantic) | +| pydantic_core | 2.46.4 | Python | runtime | Samuel Colvin , Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com>, David Montague , David Hewitt , Sydney Runkle , Victorien Plot | [https://github.com/pydantic](https://github.com/pydantic) | +| pytest | 9.1.1 | Python | dev | Brianna Laugher, Bruno Oliveira, Floris Bruynooghe, Freya Bruhin, Holger Krekel, Others (See AUTHORS), Ronny Pfannschmidt | [https://docs.pytest.org/en/latest/](https://docs.pytest.org/en/latest/) | +| PyYAML | 6.0.3 | Python | runtime | Kirill Simonov | [https://pyyaml.org/](https://pyyaml.org/) | +| typing-inspection | 0.4.2 | Python | runtime | Victorien Plot | [https://github.com/pydantic/typing-inspection](https://github.com/pydantic/typing-inspection) | +| urllib3 | 2.7.0 | Python | runtime | Andrey Petrov | [https://github.com/urllib3/urllib3/blob/main/CHANGES.rst](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) | +| @types/react | 19.3.0 | npm | dev | _see upstream_ | [https://github.com/DefinitelyTyped/DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped) | +| @types/react-dom | 19.3.0 | npm | dev | _see upstream_ | [https://github.com/DefinitelyTyped/DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped) | +| @vitejs/plugin-react | 6.1.1 | npm | dev | Evan You | [https://github.com/vitejs/vite-plugin-react](https://github.com/vitejs/vite-plugin-react) | +| plotly.js-cartesian-dist-min | 3.7.0 | npm | runtime | Plotly, Inc. | [https://github.com/plotly/plotly.js](https://github.com/plotly/plotly.js) | +| react | 19.3.0 | npm | runtime | _see upstream_ | [https://github.com/react/react](https://github.com/react/react) | +| react-dom | 19.3.0 | npm | runtime | _see upstream_ | [https://github.com/react/react](https://github.com/react/react) | +| vite | 8.3.1 | npm | dev | Evan You | [https://github.com/vitejs/vite](https://github.com/vitejs/vite) | + +### Mozilla Public License 2.0 + +Full license text: [https://www.mozilla.org/en-US/MPL/2.0/](https://www.mozilla.org/en-US/MPL/2.0/). + +| Package | Version | Ecosystem | Type | Copyright | Source | +| --- | --- | --- | --- | --- | --- | +| certifi | 2026.5.20 | Python | runtime | Kenneth Reitz | [https://github.com/certifi/python-certifi](https://github.com/certifi/python-certifi) | + +### Python Software Foundation License + +Full license text: [https://docs.python.org/3/license.html](https://docs.python.org/3/license.html). + +| Package | Version | Ecosystem | Type | Copyright | Source | +| --- | --- | --- | --- | --- | --- | +| typing_extensions | 4.15.0 | Python | runtime | "Guido van Rossum, Jukka Lehtosalo, Łukasz Langa, Michael Lee" | [https://github.com/python/typing_extensions](https://github.com/python/typing_extensions) | + +--- + ## Bundled Datasets Datasets and other non-code assets distributed with this repository, with their attribution requirements. These are listed both here and as rows in the license tables above. diff --git a/README.md b/README.md index 641886c1..da396d7a 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,12 @@ databricks workspace import-dir skills /Workspace/.assistant/skills Genie picks them up on next use — verify with *"List the Impulse skills you can use."* For Claude Code and other tools (including how to refresh after edits), see [`skills/README.md`](skills/README.md). +## Apps + +The [`apps/`](apps/) folder contains optional Databricks Apps built on Impulse. They are separate projects, not part of the `databricks-impulse` package, and are installed on their own. + +- [`apps/adhoc-reporting`](apps/adhoc-reporting/) — the **Impulse App**: build reports step by step or by describing them to an assistant, run them on serverless compute and chart the results. See the [Impulse App documentation](https://databrickslabs.github.io/impulse/app/) for requirements, installation and architecture. + ## Requirements Python 3.12, PySpark 4.0, Delta Lake 4.0. The full dependency list is in [pyproject.toml](pyproject.toml). diff --git a/apps/adhoc-reporting/.env.example b/apps/adhoc-reporting/.env.example new file mode 100644 index 00000000..f9d1a613 --- /dev/null +++ b/apps/adhoc-reporting/.env.example @@ -0,0 +1,4 @@ +DATABRICKS_CONFIG_PROFILE= +DATABRICKS_WAREHOUSE_ID= +IMPULSE_DEMO_CATALOG= +IMPULSE_DEMO_SCHEMA= diff --git a/apps/adhoc-reporting/.gitignore b/apps/adhoc-reporting/.gitignore new file mode 100644 index 00000000..466c1b0b --- /dev/null +++ b/apps/adhoc-reporting/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +.venv/ +node_modules/ +.claude/ +*.egg-info/ +.DS_Store +CLAUDE.md +.databricks/ +.vscode/ +frontend/dist/ +*.tsbuildinfo +.isaac/ +.env diff --git a/apps/adhoc-reporting/.python-version b/apps/adhoc-reporting/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/apps/adhoc-reporting/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/apps/adhoc-reporting/Makefile b/apps/adhoc-reporting/Makefile new file mode 100644 index 00000000..def5b56f --- /dev/null +++ b/apps/adhoc-reporting/Makefile @@ -0,0 +1,61 @@ +-include .env +export + +APP ?= impulse-adhoc-reporting +BUNDLE_VAR_warehouse_id = $(DATABRICKS_WAREHOUSE_ID) +WHEEL := $(shell sed -n 's/^wheel = "\(.*\)"/\1/p' impulse.lock) +# Framework the contract tests build against: the pinned release wheel, or the source in this repo with FRAMEWORK=../.. +FRAMEWORK ?= $(WHEEL) +# Install from uv.lock as it is, so a local package mirror can't rewrite it; only `make lock` changes it. +UV_RUN := uv run --frozen --python 3.11 +DEPLOY_ENV := DATABRICKS_CONFIG_PROFILE DATABRICKS_WAREHOUSE_ID +WORKSPACE_ENV := $(DEPLOY_ENV) IMPULSE_DEMO_CATALOG IMPULSE_DEMO_SCHEMA +require = @for v in $(1); do [ -n "$$(printenv $$v)" ] || { echo "$$v is not set: copy .env.example to .env and fill it in"; exit 1; }; done + +.PHONY: lock test live smoke smoke-persist eval seed frontend deploy logs + +lock: + uv lock --python 3.11 + @# Keep the lockfiles on the public registries, also when they were resolved through a Databricks mirror. + @perl -pi -e 's|registry = "https://[^"]*"|registry = "https://pypi.org/simple"|g' uv.lock + @perl -pi -e 's|https://pypi-proxy\.[a-z]+\.databricks\.com/|https://files.pythonhosted.org/|g' uv.lock + @perl -pi -e 's|https://npm-proxy\.[a-z]+\.databricks\.com/|https://registry.npmjs.org/|g' frontend/package-lock.json + uv export --format requirements-txt --no-hashes --no-dev --no-emit-project --no-annotate --no-header --frozen -o requirements.txt + +test: + $(UV_RUN) pytest -q tests/unit + uv run --no-project --python 3.12 --with "databricks-impulse[local-dev] @ $(FRAMEWORK)" --with fastapi --with pyyaml --with pytest \ + pytest -q -p no:cacheprovider -W ignore::UserWarning tests/contract + +live: + $(call require,$(WORKSPACE_ENV)) + $(UV_RUN) pytest -q tests/live + +smoke: + $(call require,$(WORKSPACE_ENV)) + PYTHONPATH=. $(UV_RUN) python scripts/smoke_run.py --name demo_full --spec tests/fixtures/specs/demo_full.json + +smoke-persist: + $(call require,$(WORKSPACE_ENV)) + PYTHONPATH=. $(UV_RUN) python scripts/smoke_run.py --name demo_full_persist --spec tests/fixtures/specs/demo_full.json --persist + +eval: + $(call require,$(WORKSPACE_ENV)) + PYTHONPATH=. $(UV_RUN) python scripts/eval_agent.py + +seed: + $(call require,$(WORKSPACE_ENV)) + uv run --no-project --python 3.12 --with databricks-sdk python scripts/seed_demo.py + +frontend: + cd frontend && npm ci --silent && npm run build + +deploy: frontend + $(call require,$(DEPLOY_ENV)) + @git diff --quiet -- . && git diff --cached --quiet -- . || (echo "Commit changes before deploying" && exit 1) + DATABRICKS_BUNDLE_ENGINE=direct databricks bundle deploy + DATABRICKS_BUNDLE_ENGINE=direct databricks bundle run impulse_app + +logs: + $(call require,DATABRICKS_CONFIG_PROFILE) + databricks apps logs $(APP) --tail-lines 300 diff --git a/apps/adhoc-reporting/README.md b/apps/adhoc-reporting/README.md new file mode 100644 index 00000000..745804df --- /dev/null +++ b/apps/adhoc-reporting/README.md @@ -0,0 +1,45 @@ +# Impulse App + +A Databricks App for building [Impulse](https://databrickslabs.github.io/impulse) reports on measurement data from vehicles, machines and other units under test. Users pick data, units under test, signals, events and aggregations step by step or describe the report to an assistant; the app generates one Impulse notebook, runs it as a serverless job and charts the gold tables it writes. + +The app is optional and not part of the `databricks-impulse` package. The [Impulse App documentation](https://databrickslabs.github.io/impulse/app/) describes what it does, its [requirements](https://databrickslabs.github.io/impulse/app/requirements), the [installation](https://databrickslabs.github.io/impulse/app/install) and the [architecture](https://databrickslabs.github.io/impulse/app/architecture). + +## Code layout + +``` +React + Vite (frontend/) report steps, results charts, chat window +FastAPI (app.py, server/) REST API and assistant, all calls as the user + datamodel.py, catalog.py table discovery, units under test and channels through the SQL warehouse and UC + spec.py, tsal.py, codegen.py report spec, expression checks, notebook generation + store.py, preferences.py reports and per-user settings as workspace files in /Users//Impulse/ + runs.py, results.py pre-flight, serverless jobs as the app service principal, gold queries + agent/ tools, prompt, loop, skills loader +skills/impulse/ Impulse framework skills, copied from the pinned tag +skills/app/ skills that teach the assistant to operate the app +``` + +## Development + +Run the commands from this folder. `make test` needs no workspace. The other targets read a gitignored `.env`: copy `.env.example` and set your Databricks CLI profile, a SQL warehouse id, and the catalog and schema for the Impulse demo data. + +| Command | Does | +|---|---| +| `make test` | Unit tests, including the skills lint, and contract tests that build every generated notebook against the pinned framework wheel | +| `make test FRAMEWORK=../..` | Same, but the contract tests build against the framework source in this repo | +| `make seed` | Loads the Impulse demo silver tables into the demo catalog and schema | +| `make live` | Data access tests against the workspace | +| `make smoke` | Runs the fixture report on serverless without writing gold tables | +| `make smoke-persist` | Same, but writes the fixture report's gold tables, which `make live` reads | +| `make eval` | Runs the assistant scenarios against FMAPI | +| `make deploy` | Builds the frontend and deploys the app (commit the app first) | +| `make logs` | Shows the app logs | +| `make lock` | Updates `uv.lock` and `requirements.txt`, keeping both lockfiles on the public registries | + +## Updating Impulse + +``` +uv run --no-project --python 3.12 python scripts/sync_framework.py vX.Y.Z +make test smoke eval +``` + +The script updates `impulse.lock`, replaces `skills/impulse/` from the tag with a checksum manifest and lists the upstream commits since the previous pin. Pin released versions only; bump the pin after each framework release. diff --git a/apps/adhoc-reporting/app.py b/apps/adhoc-reporting/app.py new file mode 100644 index 00000000..7bbd6c6b --- /dev/null +++ b/apps/adhoc-reporting/app.py @@ -0,0 +1,24 @@ +import os + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from server import errors +from server.routes import chat, data, meta, reports, runs, settings + +app = FastAPI(title="Impulse") +errors.register(app) +for module in (meta, data, reports, runs, chat, settings): + app.include_router(module.router) + +DIST = os.path.join(os.path.dirname(__file__), "frontend", "dist") + +if os.path.isdir(DIST): + app.mount("/assets", StaticFiles(directory=os.path.join(DIST, "assets")), name="assets") + + @app.get("/{path:path}") + def index(path: str) -> FileResponse: + if path.startswith("api/"): + raise HTTPException(404) + return FileResponse(os.path.join(DIST, "index.html")) diff --git a/apps/adhoc-reporting/app.yaml b/apps/adhoc-reporting/app.yaml new file mode 100644 index 00000000..5824eba5 --- /dev/null +++ b/apps/adhoc-reporting/app.yaml @@ -0,0 +1,4 @@ +command: ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] +env: + - name: DATABRICKS_WAREHOUSE_ID + valueFrom: sql-warehouse diff --git a/apps/adhoc-reporting/databricks.yml b/apps/adhoc-reporting/databricks.yml new file mode 100644 index 00000000..2cb3dd4a --- /dev/null +++ b/apps/adhoc-reporting/databricks.yml @@ -0,0 +1,54 @@ +bundle: + name: impulse-adhoc-reporting + +sync: + include: + - frontend/dist/** + exclude: + - tests/** + - scripts/** + - frontend/src/** + - frontend/package.json + - frontend/package-lock.json + - frontend/tsconfig.json + - frontend/vite.config.ts + - frontend/index.html + - Makefile + - .env.example + - uv.lock + +variables: + app_name: + default: impulse-adhoc-reporting + warehouse_id: + description: SQL warehouse the app runs user queries on + end_user_group: + default: users + +resources: + apps: + impulse_app: + name: ${var.app_name} + description: Impulse report builder + source_code_path: . + user_api_scopes: + - sql + - workspace.workspace + - model-serving + - catalog.catalogs:read + - catalog.schemas:read + - catalog.tables:read + resources: + - name: sql-warehouse + sql_warehouse: + id: ${var.warehouse_id} + permission: CAN_USE + permissions: + - level: CAN_USE + group_name: ${var.end_user_group} + +targets: + dev: + default: true + workspace: + root_path: /Workspace/Users/${workspace.current_user.userName}/.bundle/${bundle.name}/${bundle.target} diff --git a/apps/adhoc-reporting/frontend/index.html b/apps/adhoc-reporting/frontend/index.html new file mode 100644 index 00000000..17ca971b --- /dev/null +++ b/apps/adhoc-reporting/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Impulse + + +
+ + + diff --git a/apps/adhoc-reporting/frontend/package-lock.json b/apps/adhoc-reporting/frontend/package-lock.json new file mode 100644 index 00000000..aef716ff --- /dev/null +++ b/apps/adhoc-reporting/frontend/package-lock.json @@ -0,0 +1,921 @@ +{ + "name": "impulse", + "version": "4.0.0-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "impulse", + "version": "4.0.0-dev", + "dependencies": { + "lucide-react": "^1.48.0", + "plotly.js-cartesian-dist-min": "^3.7.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "~5.9.3", + "vite": "^8.3.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", + "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plotly.js-cartesian-dist-min": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/plotly.js-cartesian-dist-min/-/plotly.js-cartesian-dist-min-3.7.0.tgz", + "integrity": "sha512-kfQjcAXXByRlskP8JsrlGYRPOvB+kOehZk/qBNewgr9SOMEnBN5nwEnUGOAQ0EEgYdQX51z3VZ81aBnDgSr9Bw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/apps/adhoc-reporting/frontend/package.json b/apps/adhoc-reporting/frontend/package.json new file mode 100644 index 00000000..0b1111e3 --- /dev/null +++ b/apps/adhoc-reporting/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "impulse", + "private": true, + "version": "4.0.0-dev", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build" + }, + "dependencies": { + "lucide-react": "^1.48.0", + "plotly.js-cartesian-dist-min": "^3.7.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "~5.9.3", + "vite": "^8.3.1" + } +} diff --git a/apps/adhoc-reporting/frontend/src/App.tsx b/apps/adhoc-reporting/frontend/src/App.tsx new file mode 100644 index 00000000..0110bf62 --- /dev/null +++ b/apps/adhoc-reporting/frontend/src/App.tsx @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import { api } from "./api"; +import { ChatWindow } from "./components/ChatWindow"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { Home } from "./components/Home"; +import { ReportPage } from "./components/ReportPage"; +import { SettingsPage } from "./components/SettingsPage"; +import { TopBar } from "./components/TopBar"; +import { useAsync, useRoute } from "./hooks"; + +export default function App() { + const route = useRoute(); + const me = useAsync(() => api.me(), []); + + useEffect(() => { + api.warmup().catch(() => undefined); + }, []); + + return ( + <> + + {route.page === "home" && ( + <> + + + + )} + {route.page === "data-model" && } + {route.page === "report" && } + + + + ); +} diff --git a/apps/adhoc-reporting/frontend/src/api.ts b/apps/adhoc-reporting/frontend/src/api.ts new file mode 100644 index 00000000..15cea99c --- /dev/null +++ b/apps/adhoc-reporting/frontend/src/api.ts @@ -0,0 +1,68 @@ +import type { Channel, ChatEvent, ChatMessage, Choices, DataModelText, Issue, Me, ModelCheck, Preflight, ReportSpec, ReportSummary, Results, RunSummary, SettingsOptions, Source, UserSettings, Uut, Window } from "./types"; + +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message); + } +} + +async function request(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise { + const response = await fetch(path, { + method, + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + const detail = Array.isArray(data?.detail) + ? data.detail.map((d: { loc?: unknown[]; msg: string }) => `${(d.loc ?? []).slice(1).join(".")}: ${d.msg}`).join("; ") + : data?.detail; + throw new ApiError(response.status, data?.error?.code ?? "http_error", data?.error?.message ?? detail ?? response.statusText); + } + return data as T; +} + +const query = (params: Record) => new URLSearchParams(params).toString(); +const reportPath = (name: string) => `/api/reports/${encodeURIComponent(name)}`; + +export const api = { + me: () => request("GET", "/api/meta/me"), + warmup: () => request<{ started: boolean }>("POST", "/api/meta/warmup"), + reports: () => request<{ reports: ReportSummary[] }>("GET", "/api/reports").then((r) => r.reports), + createReport: (name: string, description: string) => request("POST", "/api/reports", { name, description }), + report: (name: string) => request("GET", reportPath(name)), + saveReport: (spec: ReportSpec) => request("PUT", reportPath(spec.name), spec), + deleteReport: (name: string) => request<{ deleted: string }>("DELETE", reportPath(name)), + check: (spec: ReportSpec, signal?: AbortSignal) => + request<{ issues: Issue[] }>("POST", `${reportPath(spec.name)}/check`, spec, signal).then((r) => r.issues), + catalogs: () => request<{ catalogs: string[] }>("GET", "/api/data/catalogs").then((r) => r.catalogs), + schemas: (catalog: string) => request<{ schemas: string[] }>("GET", `/api/data/schemas?${query({ catalog })}`).then((r) => r.schemas), + source: (catalog: string, schema: string) => request("GET", `/api/data/source?${query({ catalog, schema })}`), + uuts: (catalog: string, schema: string) => + request<{ uuts: Uut[]; issues: string[] }>("GET", `/api/data/uuts?${query({ catalog, schema })}`), + channels: (catalog: string, schema: string, uuts: string[], window: Window) => + request<{ channels: Channel[]; issues: string[] }>("POST", "/api/data/channels", { catalog, schema, uuts, window }), + preflight: (name: string) => request("POST", `${reportPath(name)}/preflight`), + run: (name: string) => request<{ preflight: Preflight; run: { job_id: number; run_id: number } | null }>("POST", `${reportPath(name)}/runs`), + runs: (name: string) => request<{ runs: RunSummary[] }>("GET", `${reportPath(name)}/runs`).then((r) => r.runs), + runDetail: (name: string, runId: number) => request("GET", `${reportPath(name)}/runs/${runId}`), + cancelRun: (name: string, runId: number) => request<{ cancelled: number }>("POST", `${reportPath(name)}/runs/${runId}/cancel`), + results: (name: string) => request("GET", `${reportPath(name)}/results`), + chatStart: (message: string, report: string | null) => request<{ turn_id: string }>("POST", "/api/chat/turns", { message, report }), + chatEvents: (turnId: string, after: number) => + request<{ events: ChatEvent[]; done: boolean }>("GET", `/api/chat/turns/${turnId}?${query({ after: String(after) })}`), + chatHistory: (report: string | null) => + request<{ messages: ChatMessage[] }>("GET", `/api/chat/history${report ? `?${query({ report })}` : ""}`).then((r) => r.messages), + chatClear: (report: string | null) => request<{ cleared: boolean }>("DELETE", `/api/chat/history${report ? `?${query({ report })}` : ""}`), + model: () => request("GET", "/api/data/model"), + checkModel: (yaml: string, catalog: string, schema: string) => request("POST", "/api/data/model/check", { yaml, catalog, schema }), + saveModel: (yaml: string) => request<{ saved: boolean; path: string }>("PUT", "/api/data/model", { yaml }), + settings: () => request("GET", "/api/settings"), + saveSettings: (choices: Choices) => request("PUT", "/api/settings", choices), + settingsOptions: () => request("GET", "/api/settings/options"), +}; diff --git a/apps/adhoc-reporting/frontend/src/components/Chart.tsx b/apps/adhoc-reporting/frontend/src/components/Chart.tsx new file mode 100644 index 00000000..83e4c463 --- /dev/null +++ b/apps/adhoc-reporting/frontend/src/components/Chart.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; + +const AXIS = { gridcolor: "#2a2a2e", zerolinecolor: "#3a3a40", linecolor: "#3a3a40", tickcolor: "#3a3a40", automargin: true }; + +const BASE = { + paper_bgcolor: "rgba(0,0,0,0)", + plot_bgcolor: "rgba(0,0,0,0)", + font: { family: "-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif", color: "#a1a1aa", size: 12 }, + margin: { l: 64, r: 16, t: 12, b: 64 }, + hoverlabel: { bgcolor: "#1c1c1f", bordercolor: "#3a3a40", font: { color: "#f4f4f5" } }, + bargap: 0.08, +}; + +export const HEAT = [ + [0, "#141416"], + [0.35, "#7a1a10"], + [0.7, "#ff3621"], + [1, "#ffffff"], +]; + +type Axis = Record; + +export function Chart({ data, layout, height = 320 }: { data: object[]; layout: { xaxis?: Axis; yaxis?: Axis } & Record; height?: number }) { + const ref = useRef(null); + + useEffect(() => { + let live = true; + const element = ref.current; + void import("plotly.js-cartesian-dist-min").then(({ default: Plotly }) => { + if (!live || !element) return; + void Plotly.react( + element, + data, + { ...BASE, ...layout, height, xaxis: { ...AXIS, ...layout.xaxis }, yaxis: { ...AXIS, ...layout.yaxis } }, + { displaylogo: false, responsive: true, modeBarButtonsToRemove: ["select2d", "lasso2d", "autoScale2d"] }, + ); + }); + return () => { + live = false; + }; + }, [data, layout, height]); + + useEffect(() => { + const element = ref.current; + return () => { + if (element) void import("plotly.js-cartesian-dist-min").then(({ default: Plotly }) => Plotly.purge(element)); + }; + }, []); + + return
; +} diff --git a/apps/adhoc-reporting/frontend/src/components/ChatWindow.tsx b/apps/adhoc-reporting/frontend/src/components/ChatWindow.tsx new file mode 100644 index 00000000..dcb2c4b8 --- /dev/null +++ b/apps/adhoc-reporting/frontend/src/components/ChatWindow.tsx @@ -0,0 +1,291 @@ +import { ArrowUp, Check, Maximize2, MessageSquare, Minimize2, PanelRight, PanelRightClose, Trash2, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { api } from "../api"; +import { reportHref } from "../hooks"; +import { bridge } from "../report"; +import type { ChatEvent, ChatMessage } from "../types"; +import { Markdown } from "./Markdown"; + +type Mode = "closed" | "open" | "docked" | "expanded"; +type Geometry = { x: number; y: number; w: number; h: number }; +type Saved = { mode: Mode; last: Mode; geometry: Geometry; dockWidth: number }; +type Chip = { id: string; label: string; status: "running" | "done" | "error"; detail?: string }; +type Bubble = { role: "user" | "assistant" | "error"; text: string; tools: Chip[] }; + +const KEY = "impulse.chat"; +const MIN_W = 320; +const MIN_H = 360; +const EDGES = ["n", "s", "e", "w", "ne", "nw", "se", "sw"] as const; + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +function defaults(): Saved { + const w = 420; + const h = Math.min(600, window.innerHeight - 120); + return { mode: "closed", last: "open", geometry: { x: window.innerWidth - w - 24, y: window.innerHeight - h - 24, w, h }, dockWidth: 420 }; +} + +function clamp(g: Geometry): Geometry { + const w = Math.min(Math.max(g.w, MIN_W), window.innerWidth - 16); + const h = Math.min(Math.max(g.h, MIN_H), window.innerHeight - 16); + return { + w, + h, + x: Math.min(Math.max(g.x, 8), window.innerWidth - w - 8), + y: Math.min(Math.max(g.y, 8), window.innerHeight - h - 8), + }; +} + +function restore(): Saved { + try { + const saved = { ...defaults(), ...JSON.parse(localStorage.getItem(KEY) ?? "{}") } as Saved; + return { ...saved, geometry: clamp(saved.geometry) }; + } catch { + return defaults(); + } +} + +function resized(start: Geometry, edge: string, dx: number, dy: number): Geometry { + let { x, y, w, h } = start; + if (edge.includes("e")) w = Math.max(MIN_W, start.w + dx); + if (edge.includes("s")) h = Math.max(MIN_H, start.h + dy); + if (edge.includes("w")) { + w = Math.max(MIN_W, start.w - dx); + x = start.x + start.w - w; + } + if (edge.includes("n")) { + h = Math.max(MIN_H, start.h - dy); + y = start.y + start.h - h; + } + return clamp({ x, y, w, h }); +} + +function fromHistory(messages: ChatMessage[]): Bubble[] { + return messages.map((m) => ({ role: m.role, text: m.text, tools: m.tools.map((label, i) => ({ id: `${i}`, label, status: "done" })) })); +} + +function withAssistant(bubbles: Bubble[], change: (b: Bubble) => Bubble): Bubble[] { + const last = bubbles[bubbles.length - 1]; + if (last?.role === "assistant") return [...bubbles.slice(0, -1), change(last)]; + return [...bubbles, change({ role: "assistant", text: "", tools: [] })]; +} + +export function ChatWindow({ report }: { report: string | null }) { + const [state, setState] = useState(restore); + const drag = useRef<{ kind: string; startX: number; startY: number; geometry: Geometry; dockWidth: number } | null>(null); + const [bubbles, setBubbles] = useState([]); + const [loaded, setLoaded] = useState(undefined); + const [busy, setBusy] = useState(false); + const [draft, setDraft] = useState(""); + const body = useRef(null); + const input = useRef(null); + + useEffect(() => { + localStorage.setItem(KEY, JSON.stringify(state)); + document.documentElement.style.setProperty("--chat-dock", state.mode === "docked" ? `${state.dockWidth}px` : "0px"); + }, [state]); + + const setMode = useCallback((mode: Mode) => setState((s) => ({ ...s, mode, last: mode === "closed" ? s.last : mode })), []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setState((s) => (s.mode === "closed" ? { ...s, mode: s.last } : { ...s, mode: "closed" })); + } + }; + const onResize = () => setState((s) => ({ ...s, geometry: clamp(s.geometry) })); + window.addEventListener("keydown", onKey); + window.addEventListener("resize", onResize); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("resize", onResize); + }; + }, []); + + useEffect(() => { + if (state.mode === "closed" || busy || loaded === report) return; + setLoaded(report); + setBubbles([]); + api.chatHistory(report).then( + (messages) => setBubbles(fromHistory(messages)), + () => setBubbles([]), + ); + }, [state.mode, report, loaded, busy]); + + useEffect(() => { + body.current?.scrollTo({ top: body.current.scrollHeight, behavior: "smooth" }); + }, [bubbles]); + + useEffect(() => { + if (state.mode !== "closed") input.current?.focus(); + }, [state.mode]); + + const apply = (event: ChatEvent) => { + if (event.type === "text") setBubbles((b) => withAssistant(b, (m) => ({ ...m, text: m.text ? `${m.text}\n\n${event.text}` : event.text }))); + else if (event.type === "tool") + setBubbles((b) => + withAssistant(b, (m) => { + const chip = { id: event.id, label: event.label, status: event.status, detail: event.detail }; + const known = m.tools.some((t) => t.id === event.id); + return { ...m, tools: known ? m.tools.map((t) => (t.id === event.id ? chip : t)) : [...m.tools, chip] }; + }), + ); + else if (event.type === "spec" || event.type === "run") window.dispatchEvent(new CustomEvent(`impulse:${event.type}`, { detail: event })); + else if (event.type === "goto") { + setLoaded(event.report); + window.location.hash = reportHref(event.report, event.step); + } else if (event.type === "error") setBubbles((b) => [...b, { role: "error", text: event.message, tools: [] }]); + }; + + const send = async (text: string) => { + const message = text.trim(); + if (!message || busy) return; + setDraft(""); + setBusy(true); + setBubbles((b) => [...b, { role: "user", text: message, tools: [] }]); + try { + if (bridge.flush && bridge.report === report) await bridge.flush(); + const { turn_id } = await api.chatStart(message, report); + let after = 0; + for (let done = false; !done; ) { + await sleep(after === 0 ? 300 : 700); + const response = await api.chatEvents(turn_id, after); + response.events.forEach(apply); + after += response.events.length; + done = response.done; + } + } catch (e) { + setBubbles((b) => [...b, { role: "error", text: (e as Error).message, tools: [] }]); + } finally { + setBusy(false); + } + }; + + const clear = async () => { + await api.chatClear(report).catch(() => undefined); + setBubbles([]); + }; + + const begin = (kind: string) => (e: ReactPointerEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + if (kind === "move" && state.mode !== "open") return; + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + drag.current = { kind, startX: e.clientX, startY: e.clientY, geometry: state.geometry, dockWidth: state.dockWidth }; + }; + + const move = (e: ReactPointerEvent) => { + const d = drag.current; + if (!d) return; + const dx = e.clientX - d.startX; + const dy = e.clientY - d.startY; + if (d.kind === "move") setState((s) => ({ ...s, geometry: clamp({ ...d.geometry, x: d.geometry.x + dx, y: d.geometry.y + dy }) })); + else if (d.kind === "dock") setState((s) => ({ ...s, dockWidth: Math.min(Math.max(MIN_W, d.dockWidth - dx), window.innerWidth * 0.6) })); + else setState((s) => ({ ...s, geometry: resized(d.geometry, d.kind, dx, dy) })); + }; + + const end = () => { + drag.current = null; + }; + + if (state.mode === "closed") { + return ( + + ); + } + + const style = + state.mode === "docked" + ? { top: "var(--topbar)", right: 0, bottom: 0, width: state.dockWidth } + : state.mode === "expanded" + ? { top: "6vh", left: "8vw", width: "84vw", height: "88vh" } + : { left: state.geometry.x, top: state.geometry.y, width: state.geometry.w, height: state.geometry.h }; + + return ( +
+ {state.mode === "open" && EDGES.map((edge) =>
)} + {state.mode === "docked" &&
} +
+ + + Assistant + + {report ?? "No report open"} + + + + + +
+
+ {bubbles.map((b, i) => ( +
+ {b.tools.length > 0 && ( +
+ {b.tools.map((t) => ( + + {t.status === "running" ? : t.status === "done" ? : } + {t.label} + + ))} +
+ )} + {b.role === "assistant" ? b.text && : b.text} +
+ ))} + {busy && bubbles[bubbles.length - 1]?.role === "user" && ( +
+ + Thinking +
+ )} +
+
{ + e.preventDefault(); + void send(draft); + }} + > +